Skip to content
localvoidPublic

About

A TypeScript-first infrastructure automation framework — a programmatic alternative to YAML-driven tools like Ansible.

Topics

Resources

Contributing

Stars

2 stars

Watchers

1 watching

Forks

Repository files navigation

sysopkit

Core of SysopKit: execution context, apply(), connectors, middleware, inventory, typed events, and base operations. Zero external runtime dependencies.

Full docs: https://www.sysopkit.com

Requirements

  • Bun (or Node 22+ with AbortSignal.any)
  • OpenSSH client for SSHConnector; rsync for rsyncPush/rsyncPull; podman for PodmanConnector

Installation

bun add sysopkit
# pnpm add sysopkit
# npm install sysopkit

Quick Start

import { apply } from 'sysopkit';
import { main } from 'sysopkit/start';
import { resolveInventory } from 'sysopkit/inventory';
import { sudo } from 'sysopkit/middleware/sudo';
import { sh } from 'sysopkit/op/sh';
import { createFile } from 'sysopkit/op/file';

await main(async () => {
  await using hosts = resolveInventory({
    groups: {
      web: {
        hosts: {
          'web-1': { host: '192.168.1.10', user: 'admin' },
        },
      },
    },
  });

  await apply('setup nginx', hosts.getByGroup('web'), async () => {
    await sudo(async () => {
      await sh('apt-get install -y nginx');
      await createFile({
        path: '/etc/nginx/sites-available/default',
        content: 'server { listen 80; }',
      });
    });
  });
});

main() is the CLI script entry point: it runs the workflow, sets process.exitCode (1 on failure), and returns the StartResult. At debug verbosity it rethrows the failure after setting the exit code, so bugs surface with a full stack trace. Use start() instead when you need to handle the { success, result | error, duration } result yourself — it never throws and never touches the exit code.

Operations

Idempotent where marked. Non-idempotent helpers (sh, exec, curl, …) run every time.

Module Import Highlights
exec sysopkit/op/exec spawn(cmd, signal?), exec(cmd, opts?)
sh sysopkit/op/sh sh(script, opts?) via sh -c
bash sysopkit/op/bash bash(script, opts?)
file sysopkit/op/file Idempotent createFile, createDir, createLink; readFile, writeFile, deleteFile, deleteDir, touchFile, sha256
users sysopkit/op/users Idempotent createUser, deleteUser, createGroup, deleteGroup; getCurrentUser, parsePasswdFile, parseGroupFile
rsync sysopkit/op/rsync rsyncPush(options), rsyncPull(options)
curl sysopkit/op/curl curl({ url, … })
tar sysopkit/op/tar tar({ src, dst }), untar({ src, dst })
temp sysopkit/op/temp withTempFile(fn, opts?), withTempDir(fn, opts?) (scoped cleanup)
ini sysopkit/op/ini serializeIni(data) (typed sections)
mount sysopkit/op/mount mount({ src, path, … }), umount({ path }), mountInfo({ path }), parseFstab, serializeFstab
net sysopkit/op/net parseHosts, serializeHosts (/etc/hosts)
ssh sysopkit/op/ssh serializeSshConf(config) (sshd_config)
which sysopkit/op/which which(name), resolveTools(names) via command -v
gpg sysopkit/utils/gpg parseGpgKey, showGpgKeys

Connectors

import { LocalConnector } from 'sysopkit/connector/local';
import { SSHConnector } from 'sysopkit/connector/ssh';
import { PodmanConnector } from 'sysopkit/connector/podman';
import { apply } from 'sysopkit';
import { start } from 'sysopkit/start';

await start(async () => {
  // SSH with ControlMaster multiplexing
  await using ssh = new SSHConnector({ host: '192.168.1.1', user: 'sysop' });
  await apply('example', ssh, async () => {
    /* ops here */
  });

  await using local = new LocalConnector();
  await using pod = new PodmanConnector({ host: 'my-app' });
});

Connectors implement connect(signal?), spawn(cmd[], signal?), and AsyncDisposable — always use await using.

Middleware

Middleware wraps the current connector for the scope of fn:

import { sudo } from 'sysopkit/middleware/sudo';
import { trace } from 'sysopkit/middleware/trace';
import { expectStderrPrompt } from 'sysopkit/middleware/expect';

await sudo(
  async () => {
    /* every spawn is prefixed with sudo */
  },
  { user: 'root' },
);

await trace(async () => {
  /* stdout/stderr streamed to the reporter */
});

await expectStderrPrompt(
  async () => {
    /* answer interactive prompts */
  },
  { pattern: /passphrase/, response: '\n' },
);
  • sudo(fn, options?) — honors SUDO_USER, SUDO_PASSWORD, SUDO_PRESERVE_ENV, SUDO_ROLE vars.
  • trace(fn, options?) — TransformStream piping with reporter output.
  • expectStderrPrompt(fn, { pattern, response }) — watches stderr, writes to stdin.
  • TransformCmdMiddleware (sysopkit/middleware/transform-cmd) — rewrite cmd[] before spawn.

Inventory and Apply

import { resolveInventory } from 'sysopkit/inventory';

await using hosts = resolveInventory(INVENTORY, {
  // optional custom factories, keyed by host prefix
  connectors: { 'k8s:': (host, h) => new MyConnector(host) },
});

hosts.getByGroup('web');
hosts.getByTag('frontend');
hosts.getByName('web-1');
hosts.match('web-*');
hosts.getAll();

Host prefixes select the connector: ssh: (default) and pod:. Variables merge with precedence inventory → group → host.

// Single host — returns { success, conn, result }, throws ApplyError on failure
await apply('name', connector, async (ctx) => {});

// Many hosts — parallel batches, returns ApplyResult[]; throws ApplyError past threshold
await apply('name', hosts.getAll(), async (ctx) => {}, { batchSize: 5, maxFailPercent: 20 });

Events and Change Tracking

import { onChange, latch, emitChanged } from 'sysopkit';
import { task } from 'sysopkit';

const restart = latch();
await onChange(restart, async () => {
  await task('configure', async () => {
    // idempotent ops call emitChanged() internally when they modify state
    emitChanged({ type: 'file', resource: '/etc/foo.conf', to: 'updated' });
  });
});

if (restart()) {
  // restart the service
}

emit(event, data) propagates up the context parent chain; onChange(handler, fn) subscribes to CHANGE_EVENT for the duration of fn.

Utilities

import { retry } from 'sysopkit';
import { timeout } from 'sysopkit';
import { sleep } from 'sysopkit';
import { waitUntil, waitForReady } from 'sysopkit';

await retry({ attempts: 3, delay: 1000, backoff: 'exponential' }, () =>
  sh('curl -sf http://api/health'),
);
await timeout(30_000, () => sh('long-running-command')); // throws TimeoutError
await sleep(5000); // abort-aware; rejects with signal.reason on cancellation
await waitUntil(async () => ((await tryReadFile('/proc/cmdline')) ?? '').includes('ready'), {
  describe: 'ready flag',
}); // reboot-tolerant predicate wait, throws TimeoutError at timeoutMs
await waitForReady(new SSHConnector({ host: '10.0.1.1' })); // connector readiness probe

retry skips AbortError and accepts a retryOn(err) predicate.

Dry Run, Verbosity, Errors

// Dry run: SYSOPKIT_DRY_RUN=1 or start(fn, { dryRun: true })
// Idempotent ops emit change events but change nothing; guard the rest:
import { context } from 'sysopkit';
if (!context().dryRun) {
  /* non-idempotent work */
}

Verbosity via SYSOPKIT_VERBOSITY: minimal (0), normal (1, default), trace (2), debug (3, includes utilities).

Live footer (TUI) on interactive terminals shows one line per concurrent branch (deeper tasks reuse their slot). Disable with tui: false or TERM=dumb.

Error Meaning
OperationError Op failure with cause chain
ConnectorError Transport failure (carries connector)
ExecError Non-zero exit (carries cmd, exitCode, stdout, stderr)
AbortError Cancelled via AbortSignal — check with isAbortError(err)
TimeoutError timeout() exceeded
ApplyError Multi-host failure; extends AggregateError, carries per-host results

License

Licensed under either of

About

A TypeScript-first infrastructure automation framework — a programmatic alternative to YAML-driven tools like Ansible.

Topics

Resources

Contributing

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages