Skip to content

JavaScript API reference

Service scripts (services/<pkg>/scripts/*.js) run in an embedded JavaScript runtime on the target server. The managed module is the value-add: a standard library of declarative building blocks so a script reads as intent, not shell mechanics.

javascript
const managed = require("managed");

The golden rule (enforced by CI, see CLAUDE.mdService scripts: declarative over shell): if a managed.* primitive already does it, use it. Never put a secret in a command string or argv, pass it via secretEnv or a 0600 file. Don't copy-paste logic: universal chores live in the managed stdlib, tool-specific logic in your package's own library. managed.runCommand is for the genuinely bespoke; if it's bespoke shell, add a // raw-shell-ok: <reason> comment.

The runtime is goja (ECMAScript 5.1). Use var/function; a top-level return is a syntax error, guard early exits with if/else or a function. Date/Math/JSON work; there is no Node fs/process/fetch, reach the box only through managed.*.

Error handling

Every managed.* failure is thrown as a normal JS exception: try/catch catches it, finally always runs (use it for cleanup that must happen on failure — removing credential files, restoring a service), and e.message carries the plain-language reason. An uncaught failure aborts the script and surfaces to the operator with the same message.

javascript
managed.files.write(cnf, secretContent, { sudo: true, mode: "0600" });
try {
  managed.runCommand("mysql --defaults-file=" + cnf + " < schema.sql", { sudo: true });
} finally {
  managed.files.delete(cnf, { sudo: true }); // runs even when the load fails
}

The one exception: an internal fault in the runtime itself (the equivalent of a crash) is not catchable and always aborts the script — a script's catch can't mask a real bug.


Contents


Parameters

managed.params holds this node's config, values from config: in managed.yaml, auto-generated secrets, and node metadata. A secret is minted during managed apply for a required string param that has no value from its default, your config, or a previously stored secret — then persisted in state, so every later apply, service run, and scheduled run sees the same value (reading params.x never creates anything). Keys are exactly as declared in your service.yaml (snake_case by convention):

javascript
const params = managed.params;
params.root_password     // an auto-generated secret declared in service.yaml params
params.max_connections   // a user config value (config: in managed.yaml)
params._serviceName      // service name, e.g. "production-database"
params._serverName       // this node's name, e.g. "production-database-primary"
params._role             // this node's role, e.g. "primary"
params._index            // this node's index within its role (1, 2, 3, …)
params._instanceID       // internal instance id (apply, schedules, instance-scoped runs)

_index is 1-based — the first node of a role is 1. One gap: managed backup runs scripts with _serviceName/_serverName/_role but no _index/_instanceID.

Arguments

managed.args is the per-invocation input. For a command run with managed service run … restore --arg confirm=yes it holds exactly the --arg pairs. For apply-run actions, verify, and managed backup, the runtime passes the resolved params map here instead — so getAll() there mirrors managed.params, it is never {}. Treat args as "what this invocation was called with" and params as configuration.

javascript
managed.args.get("confirm");   // "yes" | undefined
managed.args.getAll();          // { confirm: "yes", backup: "…" }

Logging

info is shown to the user (it becomes the progress spinner's status during apply, service run, backup, and scheduled runs); warn/error/debug go to the -v debug log.

javascript
managed.log.info("Replication configured");
managed.log.warn("No private IP, binding to localhost only");
managed.log.error("Connection failed");
managed.log.debug("raw status: " + out);

Commands

Prefer the array (argv) form: each element is shell-quoted for you (no escaping, no injection), and secrets ride in secretEnv, exported on the box, kept out of the tool's argv (where ps could see it) and out of debug logs.

javascript
const r = managed.runCommand({
  argv: ["mysql", "-u", "root", "-e", "SHOW REPLICA STATUS\\G"],
  secretEnv: { MYSQL_PWD: managed.params.root_password },
  sudo: true,
});
// r => { stdout, stderr, output (stdout+stderr), exitCode }

A string form remains for genuinely bespoke shell (pipes, redirection). placeholders are interpolated shell-escaped from vars:

javascript
// raw-shell-ok: streaming pipe, no primitive fits
managed.runCommand("gunzip -c {{f}} | psql -U postgres", {
  sudo: true,
  vars: { f: path },
  secretEnv: { PGPASSWORD: pass },
});
OptionApplies toDescription
argvobject formarray of args; each shell-quoted
sudobothrun with sudo
envbothenvironment exported ahead of the command
secretEnvbothmerged with env and handled identically — it exists to mark a value as a secret for reviewers and the CI guard; put passwords here
varsstring form → shell-escaped value

env/secretEnv are exported ahead of the command, so a value with $, spaces, or quotes is passed literally. The exports travel over the SSH connection's own input stream (or a 0600 file for streaming transfers) — never in any command line — so a secret can't be read from the process list on the box. Neither one's values are ever written to the debug log (only the secret-free command text is logged).

Sleep

javascript
managed.sleep(2000);   // milliseconds

Files

javascript
managed.files.write(path, content, { sudo, mode });   // write content (mode e.g. "0600")
//   -> bool: true when the file actually changed (created or rewritten). An
//   already-identical file is left untouched (an explicit mode is still enforced),
//   so a configure script can restart only on real changes. upload/template too.
managed.files.read(path, { sudo });                    // -> string
managed.files.exists(path, { sudo });                  // -> bool (path is a quoted literal)
managed.files.exists(pattern, { sudo, glob: true });   // -> bool: does ANY match exist?
//   e.g. managed.files.exists("/var/lib/postgresql/*/main/standby.signal", { sudo: true, glob: true })
//   Without glob:true the path is quoted literally, so a * never expands.
managed.files.chmod(path, "0644", { sudo, recursive });          // recursive: true adds -R
managed.files.chown(path, "postgres:postgres", { sudo, recursive });
managed.files.delete(pathOrPaths, { sudo });           // idempotent rm -f; string or array
managed.files.mkdir(path, { mode, owner, sudo });      // mkdir -p (+ chmod/chown)
managed.files.upload(localPath, remotePath, { sudo, mode });  // ship a file from the package dir
managed.files.template(localPath, remotePath, vars, { sudo, mode });  // render + write (see Templates)

// Credential / scratch files: written 0600, handed to the callback, ALWAYS deleted after
// (even if it throws). Use it so a secret reaches a tool via a file, never argv.
managed.files.withTempFile(content, { mode, sudo }, function (path) {
  return managed.runCommand("mysql -u root < " + managed.sh.quote(path), {
    secretEnv: { MYSQL_PWD: pass }, sudo: true,
  });
});

mode is applied when the file is created, before any content lands in it, so a 0600 secret file is never readable by others — not even briefly.

Templates

Render a template from the package directory ( placeholders, not shell-escaped, this is config text, not a command):

javascript
managed.template.render("templates/redis.conf.tmpl", { port: 6379 });  // -> string
managed.files.template("templates/redis.conf.tmpl", "/etc/redis/redis.conf",
  { port: 6379, password: managed.params.admin_password }, { sudo: true, mode: "0640" });

Packages

All managed.packages.* calls always run with sudo (there is no option to turn that off — apt requires root):

javascript
managed.packages.isInstalled("redis-server");            // -> bool
managed.packages.install(["redis-server", "curl"]);
managed.packages.remove("redis-server");
managed.packages.updateCache();
managed.packages.upgrade();                              // apt-get upgrade
managed.packages.autoremove();
// Add a third-party apt repo (signing key dearmored to keyPath, then apt-get update):
managed.packages.addSigningKey("https://example/key.pub", "/etc/apt/keyrings/x.gpg");
managed.packages.addRepo("google-chrome",
  "deb [signed-by=/etc/apt/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main\n",
  { keyUrl: "https://dl.google.com/linux/linux_signing_key.pub", keyPath: "/etc/apt/keyrings/google-chrome.gpg" });

Users

Idempotent system-user lifecycle:

javascript
managed.users.ensureSystem("node_exporter");                       // --system, /usr/sbin/nologin, no home
managed.users.ensureSystem("hermes", { createHome: true, homeDir: "/opt/hermes", shell: "/bin/bash", uid: 900 });
managed.users.remove("node_exporter");                              // userdel if present

Systemd

javascript
managed.systemd.isActive("redis-server");   // -> bool
managed.systemd.restart("mysql");
managed.systemd.start(name); managed.systemd.stop(name); managed.systemd.reload(name);
managed.systemd.enable(name); managed.systemd.disable(name);   // disable is idempotent
managed.systemd.daemonReload();

managed.systemd.unit(name, spec) writes a unit, daemon-reloads, and (by default) enables it. It returns true when the unit file actually changed (created or rewritten), so a configure script can bounce the service only on real changes:

javascript
managed.systemd.unit("hermes", {
  description: "Hermes Agent",
  after: "network.target",                 // string or array
  wants: "network-online.target",          // string or array
  requires: "postgresql.service",          // string or array
  user: "hermes", group: "hermes",
  workingDirectory: "/opt/hermes",
  environmentFile: "/opt/hermes/hermes.env",
  environment: { FOO: "bar" },             // {K:V} or ["K=V"]
  execStartPre: "/opt/hermes/preflight",   // single string (not an array)
  execStart: "/opt/hermes/venv/bin/hermes gateway",
  execReload: "/bin/kill -HUP $MAINPID",   // single string
  execStop: "/opt/hermes/shutdown",        // single string
  type: "simple",                          // "oneshot" etc.
  remainAfterExit: false,                  // true for boot-time oneshot units
  restart: "on-failure", restartSec: 10, limitNOFILE: 65536,
  wantedBy: "multi-user.target",
  enable: true, start: false, daemonReload: true,
});

start: true runs systemctl restart (not start) so freshly written unit content takes effect — it bounces the unit if it was already running.

Pass content: "<verbatim unit file>" instead of the structured fields when you need full control.

Firewall

Declarative ufw management. All managed.firewall.* calls always run with sudo (they take no options):

javascript
managed.firewall.allow("ssh");
managed.firewall.allow({ from: "10.0.0.0/8", port: 6379, proto: "tcp" });
managed.firewall.reconcileUDP(51820, peerIps);   // make the allowlist for udp/51820 EXACTLY peerIps (idempotent)
managed.firewall.enable();                        // runs `ufw --force enable` (force defaults to true)
managed.firewall.status();                        // -> string

Don't pass { force: false } to enable — plain ufw enable asks a yes/no question that can't be answered over the runtime's non-interactive session, so the call fails.

Binaries

Download + (optionally) extract + install a release artifact in one call:

javascript
// direct binary (installed 0755 by default):
managed.binary.install("https://…/tool", "/usr/local/bin/tool");
// from a tarball or zip, naming the file inside the archive:
managed.binary.install(url, "/usr/local/bin/node_exporter",
  { extract: "node_exporter-1.8.2.linux-amd64/node_exporter" });

Options: extract (path inside the archive), chmod (default "0755"), sha256 (below), sudo (default true — unlike other bindings, installs run as root unless you pass sudo: false), and cleanup (default true; pass false to keep the /tmp/managed-binary-install scratch dir for debugging).

Verify what you install. These binaries run as root, so pin the download to a checksum you got from the upstream you trust. Pass sha256 and the artifact is downloaded to a scratch path and verified there, before it is installed (or, for an archive, before it is unpacked) — a mismatch aborts the install and leaves any previously installed binary untouched, so tampered or corrupted bytes are never live at the destination:

javascript
managed.binary.install("https://…/tool-1.8.2-linux-amd64", "/usr/local/bin/tool", {
  sha256: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
});

The digest must be the 64-character hex SHA-256 of the exact artifact at url (the tarball, in the extract form). It is specific to the pinned version, so update it whenever you bump the URL. A malformed digest is rejected rather than silently skipped. Omit sha256 to keep the (unverified) legacy behavior.

Debconf

Preseed Debian package answers (no heredoc in JS). Accepts an array of lines or one string with the full debconf body; always runs with sudo:

javascript
managed.debconf.setSelections([
  "postfix postfix/mailname string mail.example.com",
  "postfix postfix/main_mailer_type string 'Internet Site'",
]);

Shell & text helpers

javascript
managed.sh.quote(value);     // single-quote for safe shell inclusion
managed.sh.timestamp();      // current time as "YYYYMMDD-HHMMSS" (backup keys)

Parsing

Shape on-box command output instead of hand-rolling regex:

javascript
managed.parse.tsv(out);                  // `mysql -N -B` etc. -> [["a","b"], …]
managed.parse.tsv(out, { columns: ["schema", "table"] });   // -> [{schema, table}, …]
managed.parse.keyValues(out, { sep: ":" });  // "k: v" lines -> { k: "v" } (first sep; rest kept)
managed.parse.block(out);                // a `\G` row / `redis-cli info` section -> { key: value }

Backups & retention

applyRetention is pure, it returns the partition; you do the deletes:

javascript
const plan = managed.backup.applyRetention(managed.storage.list(prefix), {
  keepDaily: 5, keepWeekly: 1, keepMonthly: 1, keepRecent: 0,
});
plan.deleted.forEach(function (k) { managed.storage.delete(k); });
// plan.kept is newest-first; plan.kept[plan.kept.length-1] is the oldest retained.

managed.backup.parseKey("backups/redis/20260615-120000.rdb.gz");
// -> { dayKey: "20260615", weekKey: "2026w23", monthKey: "2026m6" } | null

Accepts an array of storage.list objects (uses .key) or plain key strings. Each key must contain a YYYYMMDD (pair with managed.sh.timestamp() when naming backups).

OS

javascript
managed.os.arch();   // "amd64" | "arm64" | "other"

// Ensure a memory-constrained box has swap before starting something
// memory-hungry (a database). Cloud droplets ship with no swap, so a small box
// can OOM-kill a service's first start. Idempotent — a no-op when swap is
// already active or the box has ample RAM. Returns true if it created swap.
managed.os.ensureSwap();                          // 1 GB swap when RAM < ~1.5 GB
managed.os.ensureSwap({ sizeMB: 2048, whenBelowMB: 2048 });

Networking

javascript
managed.net.publicIp();   // public IPv4 as seen externally, or "" when it can't
                          // be determined (best-effort: never throws — check for "")

Fetch (HTTP)

fetch(url, init?) is a global, web-fetch-style HTTP client. It runs from the target server, so a localhost/127.0.0.1 URL reaches a service bound there. Reach for it instead of managed.runCommand("curl ..."), a health probe reads as intent, not a curl incantation.

javascript
const res = fetch("http://127.0.0.1:9222/json/version");
if (!res.ok) throw new Error("renderer not healthy: HTTP " + res.status);
const version = res.json().Browser;

// POST JSON (an object body is encoded and Content-Type defaults to JSON)
fetch("http://127.0.0.1:8080/reload", {
  method: "POST",
  headers: { "X-Token": token },
  body: { force: true },
  timeout: 5,   // seconds; default 30
});

Two deliberate differences from the browser API, the script runtime has no event loop, so:

  • it is synchronous: fetch() returns the Response directly (no await), and res.text() / res.json() return values, not promises.
  • as in the browser, a non-2xx status is still a Response (res.ok === false); only a transport failure (DNS, connection refused, timeout, TLS) throws.

init: { method, headers, body, timeout, sudo }. A string body is sent as-is; any other value is JSON-encoded. Response: { status, ok, statusText, url, text(), json() }. (Response headers aren't exposed yet.)


Server info

managed.server.get(key) reads a field of the server the script runs on; managed.server.getAll() returns the whole object.

javascript
const ip = managed.server.get("internal_ip");   // private/overlay address to reach peers (never the public IP)
keymeaning
internal_ipaddress THIS host should use to reach peers — "127.0.0.1" when the target is this same machine (e.g. a co-located service), otherwise overlay/private; "" if none
internal_ip_layer"overlay" / "private" / "loopback" / ""
ip_address_v4 / ip_address_v6public addresses
reserved_ipretained reputation/egress IP (provider reserved/floating IP that outlives the box); "" when none
private_ipsame-fabric private address
managed_name / provider / provider_id / region / size / statusplacement
node_role / node_indexthis node's role and index
ssh_user / ssh_key_name / created_atmisc

State

Discover other servers and instances in the deployment, and read/write small KV state.

javascript
managed.state.getServer("production-database-primary");   // -> server object (same shape as server.getAll) | undefined
managed.state.getServers("cache");                         // all servers running service "cache"
managed.state.getServiceInstances("cache");                // -> [{ id, service_name, package, role, index, server_name }]
managed.state.getServiceInstance(id);                      // -> one instance | undefined
managed.state.getPrimaryServer("cache");                   // -> server hosting the primary instance | undefined
managed.state.get("certs/cache/ca.pem");                   // -> string | undefined
managed.state.set("certs/cache/ca.pem", pem);              // persist a value

The replica-finds-primary idiom:

javascript
var primary = managed.state.getPrimaryServer(managed.params._serviceName);
if (!primary) throw new Error("No primary instance found for this service.");
var host = primary.internal_ip;   // reach it over the overlay

Storage

Auto-provisioned S3-compatible object storage (provisioned when a service declares a backup schedule; credentials handled by the platform).

javascript
managed.storage.isConfigured();                 // -> bool
managed.storage.upload(key, "string contents");
managed.storage.upload(key, "file:/var/lib/redis/dump.rdb");   // upload a file from the box
managed.storage.download(key);                   // -> string (text only)
managed.storage.download(key, { toFile: "/path", sudo: true });  // binary-safe, no awscli
managed.storage.list("backups/cache/");          // -> [{ key, size, lastModified }]
managed.storage.exists(key); managed.storage.delete(key);

// Stream between storage and an on-box command WITHOUT buffering or awscli, the object
// side runs through managed; the box side only reads/writes stdin/stdout:
managed.storage.serverExec("upload", key, "mysqldump --all-databases | gzip",
  { sudo: true, secretEnv: { MYSQL_PWD: pass } });   // env/secretEnv merge, like runCommand
managed.storage.serverExec("download", key, "gunzip -c > /var/lib/redis/dump.rdb", { sudo: true });
managed.storage.serverCopy("download", key, "/remote/path", { sudo: true });  // object <-> file

Prefer serverExec/serverCopy for large or binary payloads; download(key) returns a JS string and is text-only.

Waiting

javascript
managed.wait.port(host, 6379);                                             // TCP connect
managed.wait.command("pg_isready", { timeout: 30, interval: 2, sudo: true });   // until exit 0
managed.wait.command("redis-cli ping | grep -q PONG",                     // secrets via secretEnv
  { timeout: 30, sudo: true, secretEnv: { REDISCLI_AUTH: pass } });
managed.wait.http("http://127.0.0.1:9100/metrics", { expectStatus: 200, timeout: 30 });

The options object is optional; defaults are timeout: 60 (seconds) and interval: 2. All throw on timeout. command/http accept sudo and env/secretEnv (kept out of the polled command text); wait.port ignores all three — it is a plain TCP connect.

Running other scripts

javascript
managed.script.run("scripts/helpers/foo.js");         // run a sibling script for its side effects
managed.script.run("scripts/helpers/foo.js", { k: "v" });  // the sub-script sees this as managed.args

The path is package-root-relative; the optional second argument becomes the sub-script's managed.args. For reusable logic that returns values, prefer a package library over script.run.

Verify

In a verify action, flag which phase should re-run instead of throwing:

javascript
if (!managed.systemd.isActive("redis-server")) {
  managed.verify.needsPhase("configure");
}

Valid phase names are "install", "configure", "firewall", and "schedules" — anything else is recorded as drift but re-runs nothing. Flagged phases only take effect when the verify script completes: throwing after needsPhase discards the flags and makes the next apply re-run every phase. Throw only for "something is wrong and re-running phases won't fix it".


Writing your own package library

A package can split tool-specific logic into its own JS files and require() them. This is how you add a service without changing the Go binary, the managed stdlib gives you universal building blocks; your lib/*.js composes them into tool-specific helpers.

require() has three forms:

  • require("managed") — the built-in library.
  • require("./lib/x") — a sibling file within your package (a relative path cannot escape the package dir); runs once (cached), returns its module.exports.
  • require("<package-name>") — another package's library, when your service.yaml declares it under deps:. It resolves through the package repository like any dependency; the entry point is the dep's library: path, else index.js, else lib/index.js.
javascript
// services/redis/scripts/lib/redis.js
const managed = require("managed");

function cli(args, opts) {
  opts = opts || {};
  return managed.runCommand({
    argv: ["redis-cli"].concat(args),
    secretEnv: { REDISCLI_AUTH: opts.password || "" },   // password out of argv
    sudo: opts.sudo !== false,
  });
}

function info(section, opts) {
  return managed.parse.block(cli(["info", section], opts).stdout);   // -> { role, … }
}

module.exports = { cli: cli, info: info };
javascript
// services/redis/scripts/check_replication.js
const managed = require("managed");
const redis = require("./lib/redis");

var repl = redis.info("replication", { password: managed.params.admin_password });
managed.log.info("role: " + repl.role + ", link: " + repl.master_link_status);

Guidelines:

  • Universal, domain-agnostic logic (shell/SQL escaping, timestamps, retention, parsing, user creation) belongs in the managed stdlib, reach for it, don't reimplement.
  • Tool-specific logic (a CLI wrapper, a query helper, config assembly) belongs in your package's lib/. A library is plain CommonJS: assign to module.exports.
  • Secrets go through secretEnv or managed.files.withTempFile, never argv.
  • Nest freely: a lib/ file may require("./other") (resolved relative to itself).

The shipped reference libraries are the canonical examples: services/redis/scripts/lib/redis.js, services/mysql/scripts/lib/mysql.js, and services/postgresql/scripts/lib/postgres.js.