Files
elizaos--eliza/packages/scripts/cloud/admin/bootstrap-provisioning-worker-host.mjs
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

601 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* Bootstrap the persistent Hetzner host used by
* .github/workflows/deploy-eliza-provisioning-worker.yml.
*
* This is a one-shot operator script. It creates or reuses a labeled Hetzner
* Cloud VM, installs the deploy user and system dependencies, copies the cloud
* runtime env file to /opt/eliza/cloud/.env.local, starts the systemd worker,
* and writes the GitHub environment secrets consumed by the deploy workflow.
*
* Usage:
* HCLOUD_TOKEN=... node packages/scripts/cloud/admin/bootstrap-provisioning-worker-host.mjs --environment staging
* HCLOUD_TOKEN=... node packages/scripts/cloud/admin/bootstrap-provisioning-worker-host.mjs --environment production
*/
import { spawn, spawnSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import dotenv from "dotenv";
import { warnMissingUpstash as warnMissingUpstashImpl } from "./bootstrap-warn-missing-upstash.mjs";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const cloudRoot = resolve(scriptDir, "..", "..");
const repoRoot = resolve(cloudRoot, "..");
const rmRecursiveScript = join(cloudRoot, "rm-path-recursive.mjs");
const { values } = parseArgs({
options: {
environment: { type: "string", short: "e", default: "staging" },
branch: { type: "string" },
"github-repo": { type: "string", default: "elizaOS/eliza" },
"repo-url": {
type: "string",
default: "https://github.com/elizaOS/eliza.git",
},
"env-file": { type: "string", default: join(cloudRoot, ".env.local") },
"server-name": { type: "string" },
"server-type": { type: "string", default: "cpx21" },
location: { type: "string" },
image: { type: "string", default: "ubuntu-24.04" },
"skip-github-secrets": { type: "boolean", default: false },
"skip-remote-deploy": { type: "boolean", default: false },
"allow-incomplete-env": { type: "boolean", default: false },
"replace-server": { type: "boolean", default: false },
"rotate-ssh-key": { type: "boolean", default: false },
"dry-run": { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
});
if (values.help) {
printHelp();
process.exit(0);
}
const environment = String(values.environment);
if (!["staging", "production"].includes(environment)) {
fail(
`--environment must be "staging" or "production" (received ${environment})`,
);
}
const branch = String(
values.branch ?? (environment === "production" ? "main" : "develop"),
);
const envFile = resolve(String(values["env-file"]));
const hcloudToken = readFirstEnv("HCLOUD_TOKEN");
const serverName = String(
values["server-name"] ?? `eliza-provisioning-worker-${environment}`,
);
const keyDir = join(repoRoot, ".eliza", "provisioning-worker", environment);
const keyPath = join(keyDir, "deploy_ed25519");
const publicKeyPath = `${keyPath}.pub`;
const hadExistingSshKey = existsSync(keyPath) && existsSync(publicKeyPath);
const label = {
app: "eliza-provisioning-worker",
environment,
};
if (!hcloudToken) {
fail("Set HCLOUD_TOKEN before running this script.");
}
if (!existsSync(envFile)) {
fail(`Env file not found: ${envFile}`);
}
const sourceEnv = dotenv.parse(readFileSync(envFile, "utf8"));
const location = String(
values.location ?? sourceEnv.CONTAINERS_HCLOUD_LOCATION ?? "fsn1",
);
validateRuntimeEnv(sourceEnv);
log(`environment: ${environment}`);
log(`branch: ${branch}`);
log(`server: ${serverName}`);
log(`location: ${location}`);
log(`env file: ${envFile}`);
log(`github repo: ${values["github-repo"]}`);
log(`dry run: ${values["dry-run"] ? "yes" : "no"}`);
log("");
if (values["dry-run"]) {
log("Dry run complete. No Hetzner, SSH, or GitHub changes were made.");
process.exit(0);
}
ensureSshKey();
const server = await resolveServer();
const host = getServerHost(server);
if (!host) {
fail(`Hetzner server ${server.name} has no public IPv4 address.`);
}
log(`host: ${host}`);
if (!values["skip-remote-deploy"]) {
await waitForSsh(host);
await deployWorker(host);
await writeRemoteEnv(host);
await restartWorker(host);
await assertWorkerHealthy(host);
}
if (!values["skip-github-secrets"]) {
await setGitHubSecret("ELIZA_PROVISIONING_HOST", host);
await setGitHubSecret(
"ELIZA_PROVISIONING_SSH_KEY",
readFileSync(keyPath, "utf8"),
);
}
log("");
log("done.");
log(
`GitHub environment '${environment}' now has ELIZA_PROVISIONING_HOST and ELIZA_PROVISIONING_SSH_KEY.`,
);
log("The deploy workflow can be rerun for this environment.");
function printHelp() {
console.log(`
Bootstrap the Hetzner host used by the Eliza provisioning-worker deploy workflow.
Required:
HCLOUD_TOKEN in the local environment.
A cloud runtime env file, defaulting to packages/cloud/shared/.env.local.
Examples:
HCLOUD_TOKEN=... node packages/scripts/cloud/admin/bootstrap-provisioning-worker-host.mjs --environment staging
HCLOUD_TOKEN=... node packages/scripts/cloud/admin/bootstrap-provisioning-worker-host.mjs --environment production --env-file packages/cloud/shared/.env.production
Options:
--environment staging|production GitHub environment and deploy branch default.
--branch <name> Override branch (default: develop/staging, main/production).
--env-file <path> Runtime env copied to /opt/eliza/cloud/.env.local.
--server-type <type> Hetzner type (default: cpx21).
--location <code> Hetzner location (default: CONTAINERS_HCLOUD_LOCATION or fsn1).
--replace-server Delete and recreate an existing labeled server.
--rotate-ssh-key Generate a new deploy key before creating a server.
--skip-github-secrets Do not write GitHub Actions secrets.
--skip-remote-deploy Only provision the server and secrets.
--dry-run Print the resolved plan only.
`);
}
function readFirstEnv(...keys) {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) return value;
}
return undefined;
}
function rmRecursive(targetPath) {
const result = spawnSync(process.execPath, [rmRecursiveScript, targetPath], {
cwd: cloudRoot,
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`[bootstrap-provisioning-worker-host] recursive cleanup failed for ${targetPath} with exit code ${result.status ?? "unknown"}`,
);
}
}
function validateRuntimeEnv(env) {
const required = ["DATABASE_URL", "CONTAINERS_SSH_KEY"];
const missing = required.filter((key) => !env[key]?.trim());
if (missing.length === 0 || values["allow-incomplete-env"]) {
warnMissingUpstash(env);
return;
}
fail(
[
`Runtime env file is missing required key(s): ${missing.join(", ")}`,
"The worker may start without these, but agent provisioning will fail.",
"Pass --allow-incomplete-env only if you are intentionally bootstrapping in stages.",
].join("\n"),
);
}
/**
* Sandbox containers boot a `SandboxRegistry` (packages/app-core) that
* publishes `agent:<id>:server` / `server:<name>:url` keys to the shared
* Upstash so gateway-discord and gateway-webhook can route inbound
* platform messages to them. The orchestrator reads `KV_REST_API_URL` and
* `KV_REST_API_TOKEN` from its own env and injects them into every new
* sandbox via docker-sandbox-provider. Without these on the orchestrator
* host the registration step has no credentials to publish, so Discord / WhatsApp /
* Telegram / SMS traffic to those sandboxes is black-holed. Warn loud —
* we deploy either way (some hosts intentionally skip platform routing)
* but the operator must opt-in to that silent path.
*/
function warnMissingUpstash(env) {
return warnMissingUpstashImpl(env, (s) => process.stderr.write(s));
}
function ensureSshKey() {
mkdirSync(keyDir, { recursive: true });
if (values["rotate-ssh-key"]) {
rmSync(keyPath, { force: true });
rmSync(publicKeyPath, { force: true });
}
if (existsSync(keyPath) && existsSync(publicKeyPath)) return;
log(`generating deploy ssh key in ${keyDir}`);
const result = spawnSync(
"ssh-keygen",
[
"-t",
"ed25519",
"-C",
`eliza-provisioning-worker-${environment}`,
"-f",
keyPath,
"-N",
"",
],
{ stdio: "inherit" },
);
if (result.status !== 0) fail("ssh-keygen failed");
chmodSync(keyPath, 0o600);
}
async function resolveServer() {
const existing = await listServers();
if (existing.length > 1) {
fail(
`Found ${existing.length} Hetzner servers with labels ${labelSelector()}; refusing to guess.`,
);
}
if (existing.length === 1 && values["replace-server"]) {
log(`deleting existing server ${existing[0].name} (${existing[0].id})`);
await hcloud("DELETE", `/servers/${existing[0].id}`);
await sleep(10_000);
} else if (existing.length === 1) {
if (values["rotate-ssh-key"] || !hadExistingSshKey) {
fail(
[
`Found existing server ${existing[0].name} (${existing[0].id}), but the matching local deploy key is unavailable or was rotated.`,
`Expected key path: ${keyPath}`,
"Use the original key, or rerun with --replace-server --rotate-ssh-key to recreate the host.",
].join("\n"),
);
}
log(`reusing server ${existing[0].name} (${existing[0].id})`);
return existing[0];
}
log(`creating Hetzner server ${serverName}`);
const publicKey = readFileSync(publicKeyPath, "utf8").trim();
const response = await hcloud("POST", "/servers", {
name: serverName,
server_type: values["server-type"],
location,
image: values.image,
user_data: buildUserData(publicKey),
start_after_create: true,
labels: label,
});
const serverId = response.server.id;
for (let attempt = 1; attempt <= 60; attempt++) {
const current = await hcloud("GET", `/servers/${serverId}`);
if (current.server.status === "running") return current.server;
log(
`waiting for server ${serverName} (${current.server.status}) ${attempt}/60`,
);
await sleep(5_000);
}
fail(`Server ${serverName} did not become running within 5 minutes.`);
}
async function listServers() {
const data = await hcloud(
"GET",
`/servers?label_selector=${encodeURIComponent(labelSelector())}`,
);
return data.servers ?? [];
}
function labelSelector() {
return Object.entries(label)
.map(([key, value]) => `${key}=${value}`)
.join(",");
}
function getServerHost(server) {
return server.public_net?.ipv4?.ip;
}
function buildUserData(publicKey) {
return `#!/bin/bash
set -euxo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl git openssh-client sudo nodejs npm
if ! id deploy >/dev/null 2>&1; then
useradd -m -s /bin/bash deploy
fi
usermod -aG sudo deploy
echo 'deploy ALL=(ALL) NOPASSWD:ALL' >/etc/sudoers.d/90-eliza-deploy
chmod 0440 /etc/sudoers.d/90-eliza-deploy
install -d -m 0700 -o deploy -g deploy /home/deploy/.ssh
cat >/home/deploy/.ssh/authorized_keys <<'KEYS'
${publicKey}
KEYS
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 0600 /home/deploy/.ssh/authorized_keys
install -d -m 0755 -o deploy -g deploy /opt/eliza
`;
}
async function writeRemoteEnv(host) {
const tmp = mkdtempSync(join(tmpdir(), "eliza-provisioning-env-"));
const localEnvPath = join(tmp, ".env.local");
const remoteTmpPath = `/tmp/eliza-provisioning-worker-${Date.now()}.env`;
const raw = readFileSync(envFile, "utf8");
writeFileSync(localEnvPath, buildRemoteEnv(raw, sourceEnv), { mode: 0o600 });
try {
await run("scp", [
...sshCommonArgs(),
localEnvPath,
`deploy@${host}:${remoteTmpPath}`,
]);
await ssh(
host,
[
"set -euo pipefail",
"install -d -m 0755 /opt/eliza/cloud",
`install -m 0600 ${shellQuote(remoteTmpPath)} /opt/eliza/cloud/.env.local`,
`rm -f ${shellQuote(remoteTmpPath)}`,
].join("\n"),
);
} finally {
rmRecursive(tmp);
}
return "/opt/eliza/cloud/.env.local";
}
function buildRemoteEnv(raw, parsed) {
const overlays = {
NODE_ENV: "production",
ENVIRONMENT: environment,
NEXT_PUBLIC_API_URL:
environment === "production"
? "https://api.elizacloud.ai"
: "https://api-staging.elizacloud.ai",
NEXT_PUBLIC_APP_URL:
environment === "production"
? "https://elizacloud.ai"
: "https://staging.elizacloud.ai",
};
if (!parsed.HCLOUD_TOKEN) {
overlays.HCLOUD_TOKEN = hcloudToken;
}
return `${raw.trimEnd()}
# Added by bootstrap-provisioning-worker-host.mjs.
${Object.entries(overlays)
.map(([key, value]) => `${key}=${quoteEnvValue(value)}`)
.join("\n")}
`;
}
function quoteEnvValue(value) {
return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("$", "\\$")}"`;
}
async function deployWorker(host) {
log("syncing worker checkout on remote host");
await ssh(
host,
[
"set -euo pipefail",
`REPO_URL=${shellQuote(String(values["repo-url"]))}`,
`DEPLOY_BRANCH=${shellQuote(branch)}`,
"if [ ! -d /opt/eliza/.git ]; then",
" sudo rm -rf /opt/eliza",
" sudo install -d -m 0755 -o deploy -g deploy /opt/eliza",
' git clone --branch "$DEPLOY_BRANCH" "$REPO_URL" /opt/eliza',
"fi",
"cd /opt/eliza",
'git remote set-url origin "$REPO_URL" || true',
'git fetch origin "$DEPLOY_BRANCH"',
'git checkout -B "$DEPLOY_BRANCH" "origin/$DEPLOY_BRANCH"',
"sudo chown -R deploy:deploy /opt/eliza",
"if ! command -v bun >/dev/null 2>&1; then",
' curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"',
"fi",
'export BUN_INSTALL="$HOME/.bun"',
'export PATH="$BUN_INSTALL/bin:$PATH"',
"for attempt in 1 2 3; do",
" if bun install --frozen-lockfile; then break; fi",
' if [ "$attempt" -eq 3 ]; then exit 1; fi',
' echo "bun install attempt $attempt failed; retrying in 10s..."',
" sleep 10",
"done",
].join("\n"),
);
}
async function restartWorker(host) {
log("installing and restarting systemd worker");
await ssh(
host,
[
"set -euo pipefail",
"cd /opt/eliza",
"sudo install -m 0644 packages/scripts/cloud/admin/eliza-provisioning-worker.service /etc/systemd/system/eliza-provisioning-worker.service",
"sudo systemctl daemon-reload",
"sudo systemctl enable eliza-provisioning-worker.service",
"sudo systemctl restart eliza-provisioning-worker.service",
].join("\n"),
);
}
async function assertWorkerHealthy(host) {
log("checking systemd health");
await ssh(
host,
[
"set -euo pipefail",
"SINCE_TS=\"$(date -u -d '30 seconds ago' '+%Y-%m-%d %H:%M:%S')\"",
"for attempt in $(seq 1 18); do",
" if sudo systemctl is-active --quiet eliza-provisioning-worker.service; then",
' JOURNAL=$(sudo journalctl -u eliza-provisioning-worker.service --since "$SINCE_TS" --no-pager 2>/dev/null || true)',
" if echo \"$JOURNAL\" | grep -qE '\\[provisioning-worker\\] (fatal|unhandled rejection)'; then",
' echo "$JOURNAL" | tail -n 80',
" exit 1",
" fi",
" if echo \"$JOURNAL\" | grep -q '\\[provisioning-worker\\] starting'; then",
' echo "worker is active"',
" exit 0",
" fi",
" fi",
' echo "health check attempt $attempt/18"',
" sleep 5",
"done",
"sudo systemctl status eliza-provisioning-worker.service --no-pager || true",
"sudo journalctl -u eliza-provisioning-worker.service -n 200 --no-pager || true",
"exit 1",
].join("\n"),
);
}
async function waitForSsh(host) {
log("waiting for deploy SSH");
for (let attempt = 1; attempt <= 60; attempt++) {
const result = spawnSync("ssh", [
...sshCommonArgs(),
`deploy@${host}`,
"true",
]);
if (result.status === 0) return;
if (attempt === 60)
fail(`Could not connect to deploy@${host} with ${keyPath}.`);
await sleep(5_000);
}
}
async function setGitHubSecret(name, value) {
log(`setting GitHub secret ${name}`);
await run(
"gh",
[
"secret",
"set",
name,
"--repo",
String(values["github-repo"]),
"--env",
environment,
],
value,
);
}
async function hcloud(method, path, body) {
const response = await fetch(`https://api.hetzner.cloud/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${hcloudToken}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 204) return {};
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
const message = data?.error?.message ?? response.statusText;
fail(
`Hetzner API ${method} ${path} failed (${response.status}): ${message}`,
);
}
return data;
}
function sshCommonArgs() {
return [
"-i",
keyPath,
"-o",
"BatchMode=yes",
"-o",
"IdentitiesOnly=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"ConnectTimeout=10",
];
}
async function ssh(host, script) {
await run("ssh", [...sshCommonArgs(), `deploy@${host}`, "bash -se"], script);
}
function run(command, args, stdin) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, {
cwd: repoRoot,
stdio: [stdin ? "pipe" : "ignore", "inherit", "inherit"],
});
child.on("error", rejectRun);
child.on("exit", (code) => {
if (code === 0) {
resolveRun();
} else {
rejectRun(new Error(`${command} exited with ${code}`));
}
});
if (stdin) {
child.stdin.write(stdin);
child.stdin.end();
}
}).catch((error) => fail(error.message));
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function log(message) {
console.log(message);
}
function fail(message) {
console.error(`error: ${message}`);
process.exit(1);
}