Appearance
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.md› Service scripts: declarative over shell): if amanaged.*primitive already does it, use it. Never put a secret in a command string or argv, pass it viasecretEnvor a 0600 file. Don't copy-paste logic: universal chores live in themanagedstdlib, tool-specific logic in your package's own library.managed.runCommandis 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
- Error handling
- Parameters · Arguments · Logging
- Commands · Sleep
- Files · Templates · Packages · Users · Systemd · Firewall · Binaries · Debconf
- Shell & text helpers · Parsing · Backups & retention · OS · Networking · Fetch (HTTP)
- Server info · State · Storage
- Waiting · Running other scripts · Verify
- Writing your own package library
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 },
});| Option | Applies to | Description |
|---|---|---|
argv | object form | array of args; each shell-quoted |
sudo | both | run with sudo |
env | both | environment exported ahead of the command |
secretEnv | both | merged with env and handled identically — it exists to mark a value as a secret for reviewers and the CI guard; put passwords here |
vars | string 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); // millisecondsFiles
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 presentSystemd
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(); // -> stringDon'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" } | nullAccepts 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 theResponsedirectly (noawait), andres.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)| key | meaning |
|---|---|
internal_ip | address 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_v6 | public addresses |
reserved_ip | retained reputation/egress IP (provider reserved/floating IP that outlives the box); "" when none |
private_ip | same-fabric private address |
managed_name / provider / provider_id / region / size / status | placement |
node_role / node_index | this node's role and index |
ssh_user / ssh_key_name / created_at | misc |
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 valueThe 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 overlayStorage
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 <-> filePrefer 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.argsThe 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 itsmodule.exports.require("<package-name>")— another package's library, when yourservice.yamldeclares it underdeps:. It resolves through the package repository like any dependency; the entry point is the dep'slibrary:path, elseindex.js, elselib/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
managedstdlib, 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 tomodule.exports. - Secrets go through
secretEnvormanaged.files.withTempFile, never argv. - Nest freely: a
lib/file mayrequire("./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.