paratix 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +57 -360
  2. package/dist/cli.js +2 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,43 +1,49 @@
1
1
  # Paratix
2
2
 
3
- Idempotent VPS configuration in TypeScript.
3
+ Idempotent VPS automation in TypeScript.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/paratix)](https://www.npmjs.com/package/paratix)
6
6
  [![license](https://img.shields.io/npm/l/paratix)](https://opensource.org/licenses/MIT)
7
7
  [![node](https://img.shields.io/node/v/paratix)](https://nodejs.org/)
8
8
 
9
- ## Overview
9
+ Paratix lets you manage Linux servers over SSH with TypeScript playbooks instead of YAML or ad-hoc shell scripts. You describe the desired end state of a machine, run the playbook, and Paratix changes only what is necessary.
10
10
 
11
- Paratix configures Linux servers over SSH using TypeScript playbooks. Each playbook declares the desired state of a server -- packages, files, services, firewall rules -- and Paratix enforces that state idempotently. Every module follows a check/apply pattern: `check` determines whether work is needed, `apply` makes it so.
11
+ It is built for developers and operators who want infrastructure automation that feels like application code: typed, reviewable, composable, and easy to keep in version control. You can start small on a single VPS and still keep a disciplined, repeatable workflow.
12
12
 
13
- If you have used Ansible, the idea is the same. The difference is that you write TypeScript instead of YAML, with full type safety and your existing toolchain.
13
+ The result is a practical server automation tool with a compact mental model: modules check state, modules apply state, recipes group related work, and signals run only when changes actually happened.
14
14
 
15
- ## Getting started
15
+ ## Features
16
16
 
17
- Scaffold a new project:
17
+ - **Idempotent runs**: rerunning the same playbook on an already configured server is safe.
18
+ - **TypeScript authoring**: use regular `.ts` files with imports, conditions, and editor tooling.
19
+ - **Resilient SSH flow**: reconnects after reboots and SSH port changes when modules require it.
20
+ - **Structured orchestration**: recipes and signals keep service reloads and grouped changes explicit.
21
+ - **Strong bootstrap story**: supports explicit first-run flows and strict host-key handling.
22
+ - **Practical built-in modules**: packages, files, services, users, SSH, firewall, systemd, sysctl, mount, rsync, and more.
23
+
24
+ ## Getting Started
25
+
26
+ If you want the fastest path, scaffold a project first:
18
27
 
19
28
  ```bash
20
29
  npm create paratix my-server
21
- # or: pnpm create paratix my-server
22
- # or: yarn create paratix my-server
23
- # or: bun create paratix my-server
30
+ cd my-server
31
+ npm run apply:dry
32
+ npm run apply -- --first-run
33
+ npm run apply
24
34
  ```
25
35
 
26
- This creates a project with the following structure:
36
+ If you want to install `paratix` directly:
27
37
 
28
- ```
29
- my-server/
30
- server.ts # Your playbook
31
- files/ # Templates and config files
32
- package.json
33
- tsconfig.json
38
+ ```bash
39
+ npm install paratix
34
40
  ```
35
41
 
36
- Open `server.ts` and define your server:
42
+ Create a playbook:
37
43
 
38
44
  ```typescript
39
45
  import { server } from "paratix"
40
- import { hostname, package as pkg } from "paratix/modules"
46
+ import { hostname, package as pkg, service } from "paratix/modules"
41
47
 
42
48
  export default server({
43
49
  name: "web-01",
@@ -45,13 +51,19 @@ export default server({
45
51
  ssh: {
46
52
  user: "root",
47
53
  ports: [22],
48
- privateKey: "~/.ssh/id_ed25519", // "~" is expanded by Paratix
54
+ privateKey: "~/.ssh/id_ed25519",
49
55
  },
50
- run: [hostname.set("web-01"), pkg.update("2025-03-01"), pkg.installed("nginx", "curl", "git")],
56
+ run: [
57
+ hostname.set("web-01"),
58
+ pkg.update("2026-03-01"),
59
+ pkg.installed("nginx", "curl"),
60
+ service.enabled("nginx"),
61
+ service.running("nginx"),
62
+ ],
51
63
  })
52
64
  ```
53
65
 
54
- Apply the playbook:
66
+ Apply it:
55
67
 
56
68
  ```bash
57
69
  npx paratix apply server.ts
@@ -63,362 +75,47 @@ Preview changes without applying them:
63
75
  npx paratix apply server.ts --dry-run
64
76
  ```
65
77
 
66
- For most modules, `--dry-run` reports whether Paratix would change remote state without mutating it.
67
- For SSH hardening modules, Paratix now goes one step further:
68
-
69
- - `sshd.config` validates the prospective config with `sshd -t`
70
- - `sshd.port` validates the prospective config with `sshd -t`
71
-
72
- Runtime effects are still intentionally not executed during `--dry-run`. In particular, reloads,
73
- restarts, port switches, firewall reachability, and reconnect behavior are reported as limited
74
- verification in the run output instead of being performed.
75
-
76
- ## SSH host key migration
77
-
78
- Paratix now defaults to strict host-key checking (`ssh.strictHostKeyChecking: "yes"`).
79
- Existing playbooks that relied on implicit TOFU must now opt in explicitly:
80
-
81
- ```typescript
82
- ssh: {
83
- user: "root",
84
- ports: [22],
85
- privateKey: "~/.ssh/id_ed25519", // "~" is expanded by Paratix
86
- strictHostKeyChecking: "accept-new", // explicit TOFU opt-in
87
- }
88
- ```
89
-
90
- For a safer bootstrap of brand-new hosts, pin the expected host key instead of using TOFU:
91
-
92
- ```typescript
93
- ssh: {
94
- user: "root",
95
- ports: [22],
96
- privateKey: "~/.ssh/id_ed25519", // "~" is expanded by Paratix
97
- expectedHostFingerprint: "SHA256:your-known-fingerprint",
98
- }
99
- ```
100
-
101
- You can also pin the full OpenSSH public key with `expectedHostPublicKey`.
102
-
103
- ## Core concepts
104
-
105
- ### Playbook
106
-
107
- A playbook is a TypeScript file that default-exports a `server()` call. It declares the target host, SSH credentials, environment variables, and an ordered list of modules to run.
108
-
109
- ```typescript
110
- import { server } from "paratix"
111
-
112
- export default server({
113
- name: "web-01",
114
- host: "10.0.0.1",
115
- ssh: {
116
- user: "root",
117
- ports: [22],
118
- privateKey: "~/.ssh/id_ed25519", // "~" is expanded by Paratix
119
- expectedHostFingerprint: "SHA256:your-known-fingerprint",
120
- },
121
- env: {
122
- DOMAIN: "example.com",
123
- APP_PORT: 3000,
124
- },
125
- run: [
126
- // modules go here
127
- ],
128
- signals: [
129
- // fire after run if anything changed
130
- ],
131
- })
132
- ```
133
-
134
- ### Module
135
-
136
- A module is the smallest unit of configuration. Each module has a `check` function (is the desired state already in place?) and an `apply` function (enforce the desired state). Modules are idempotent: running them twice produces the same result as running them once.
78
+ ## How Paratix Works
137
79
 
138
- ```typescript
139
- import { package as pkg, service } from "paratix/modules"
80
+ ### Playbooks
140
81
 
141
- pkg.installed("nginx") // installs nginx if missing, does nothing if present
142
- service.running("nginx") // starts nginx if stopped, does nothing if running
143
- ```
82
+ A playbook is a TypeScript file that default-exports `server(...)`. It defines the target host, SSH configuration, optional environment values, and an ordered list of modules to run.
144
83
 
145
- ### Recipe
84
+ ### Modules
146
85
 
147
- A recipe groups related modules into a reusable unit with its own signals. Signals on a recipe fire only when a module inside that recipe reported a change.
86
+ Modules are the smallest units of work. Each module checks whether its target state already exists and only applies changes when needed.
148
87
 
149
- ```typescript
150
- import { recipe } from "paratix"
151
- import { package as pkg, file, service } from "paratix/modules"
152
-
153
- const nginx = recipe(
154
- "nginx",
155
- [
156
- pkg.installed("nginx"),
157
- file.template("/etc/nginx/nginx.conf", "./files/nginx.conf.tmpl"),
158
- service.enabled("nginx"),
159
- service.running("nginx"),
160
- ],
161
- {
162
- signals: [service.reload("nginx")],
163
- }
164
- )
165
- ```
88
+ ### Recipes
166
89
 
167
- If the config file changes, `service.reload("nginx")` fires. If nothing changed, the reload is skipped.
90
+ Recipes group related modules into a named unit. They help structure larger playbooks and keep the CLI output readable.
168
91
 
169
92
  ### Signals
170
93
 
171
- Signals are modules that run only when something changed. Use them for actions like reloading a service after a config file update.
172
-
173
- At the server level, `signals` fire when any module in `run` reported a change. Inside a recipe, signals fire only when that recipe's modules changed.
174
-
175
- ```typescript
176
- export default server({
177
- // ...
178
- run: [file.template("/etc/myapp/config.yml", "./files/config.yml.tmpl")],
179
- signals: [service.restart("myapp")],
180
- })
181
- ```
182
-
183
- **Note:** `service.restart()` and `service.reload()` always apply when called. Place them in `signals`, not directly in `run`.
184
-
185
- ### Environment
186
-
187
- The `env` object makes values available to templates and conditional logic. Values can be strings, numbers, or async functions (resolved lazily on first access).
188
-
189
- ```typescript
190
- export default server({
191
- // ...
192
- env: {
193
- DOMAIN: "example.com",
194
- APP_PORT: 3000,
195
- DB_PASSWORD: async () => fetchFromVault("db-password"),
196
- },
197
- run: [
198
- /* ... */
199
- ],
200
- })
201
- ```
202
-
203
- Modules can return `meta` in their result, which merges into the environment for subsequent modules.
204
-
205
- When environment values come from multiple sources, Paratix merges them in this order, with later values winning:
206
-
207
- 1. `--env-file <path>`
208
- 2. `server({ env })`
209
- 3. `--env <key=value>`
210
-
211
- ### Templates
212
-
213
- `file.template()` deploys a file with `{{KEY}}` placeholders resolved from the environment.
214
-
215
- Template file (`files/nginx.conf.tmpl`):
94
+ Signals are deferred side effects such as `service.reload(...)` or `service.restart(...)`. They run when the surrounding scope actually changed, and can also be flushed explicitly with `signals.flush()` when you need a checkpoint inside a larger flow.
216
95
 
217
- ```
218
- server {
219
- listen 80;
220
- server_name {{DOMAIN}};
221
- proxy_pass http://127.0.0.1:{{APP_PORT}};
222
- }
223
- ```
224
-
225
- ```typescript
226
- file.template("/etc/nginx/sites-available/default", "./files/nginx.conf.tmpl")
227
- ```
228
-
229
- Use `\{{` to produce a literal `{{` in the output. Unknown keys throw an error at runtime.
96
+ ## CLI
230
97
 
231
- ## CLI reference
232
-
233
- ```
234
- paratix apply <file>
98
+ ```text
99
+ paratix apply <file> [options]
235
100
 
236
101
  Options:
237
- --dry-run Check only, don't apply. Some modules validate prospective config but cannot verify runtime restarts.
238
- --env <key=value> Set environment variable (repeatable)
239
- --env-file <path> Load .env file
240
- --reconnect-timeout <s> SSH reconnect timeout in seconds (default: 300)
241
- --verbose Show full stack traces on error
242
- --version Show version number
243
- --help Show help
244
- ```
245
-
246
- ## Module reference
247
-
248
- All modules are imported from `"paratix/modules"`.
249
-
250
- **Note:** `package` is a reserved word in JavaScript. Import it as: `import { package as pkg } from "paratix/modules"`.
251
-
252
- ### System
253
-
254
- | Namespace | Methods |
255
- | ---------- | --------------------------- |
256
- | `hostname` | `set` |
257
- | `system` | `facts`, `reboot`, `uptime` |
258
- | `sysctl` | `set` |
259
- | `mount` | `present`, `absent` |
260
-
261
- ### Packages
262
-
263
- | Namespace | Methods |
264
- | ---------------- | --------------------------------------------- |
265
- | `package` | `installed`, `absent`, `update`, `upgrade` |
266
- | `apt` | `debconf`, `distUpgrade`, `key`, `repository` |
267
- | `releaseUpgrade` | `upgrade` |
268
-
269
- ### Files
270
-
271
- | Namespace | Methods |
272
- | ---------- | ------------------------------------------------------------------------------------------------------- |
273
- | `file` | `absent`, `assemble`, `block`, `copy`, `directory`, `line`, `properties`, `replace`, `stat`, `template` |
274
- | `archive` | `extract` |
275
- | `download` | `url`, `github`, `large` |
276
- | `git` | `clone` |
277
- | `rsync` | `sync` |
278
-
279
- ### Services
280
-
281
- | Namespace | Methods |
282
- | --------- | ------------------------------------------------------------------------- |
283
- | `service` | `running`, `stopped`, `enabled`, `disabled`, `restart`, `reload`, `facts` |
284
- | `systemd` | `unit`, `daemonReload`, `masked`, `unmasked` |
285
-
286
- ### Users and groups
287
-
288
- | Namespace | Methods |
289
- | --------- | ------------------- |
290
- | `user` | `present`, `absent` |
291
- | `group` | `present`, `absent` |
292
-
293
- ### Network and security
294
-
295
- | Namespace | Methods |
296
- | --------- | ------------------------------ |
297
- | `ufw` | `enabled`, `rule` |
298
- | `ssh` | `authorizedKeys`, `knownHosts` |
299
- | `sshd` | `config`, `port` |
300
-
301
- ### Scheduling
302
-
303
- | Namespace | Methods |
304
- | --------- | ------- |
305
- | `cron` | `job` |
306
-
307
- ### Commands
308
-
309
- | Namespace | Methods |
310
- | --------- | ------- |
311
- | `command` | `shell` |
312
-
313
- ### Secrets
314
-
315
- | Namespace | Methods |
316
- | --------- | --------- |
317
- | `op` | `resolve` |
318
-
319
- ## Custom modules
320
-
321
- A custom module is an object with `name`, `check`, and `apply`:
322
-
323
- ```typescript
324
- import type { Module, ModuleResult, SshConnection, Environment } from "paratix"
325
- import { NEEDS_APPLY } from "paratix"
326
-
327
- function ensureFile(path: string, content: string): Module {
328
- return {
329
- name: `ensure-file: ${path}`,
330
-
331
- async check(ssh: SshConnection | null, env: Environment): Promise<"needs-apply" | "ok"> {
332
- if (!ssh) return NEEDS_APPLY
333
- const current = await ssh.readFile(path).catch(() => null)
334
- return current === content ? "ok" : NEEDS_APPLY
335
- },
336
-
337
- async apply(ssh: SshConnection | null, env: Environment): Promise<ModuleResult> {
338
- if (!ssh) return { status: "failed" }
339
- await ssh.writeFile(path, content)
340
- return { status: "changed" }
341
- },
342
- }
343
- }
344
- ```
345
-
346
- Rules for custom modules:
347
-
348
- - `check` returns `"ok"` or `NEEDS_APPLY` (use the exported constant, not the string `"needs-apply"`).
349
- - `apply` returns `{ status }` where status is `"changed"`, `"failed"`, `"ok"`, or `"skipped"`.
350
- - Always handle the case where `ssh` is `null` (happens for local-only modules).
351
- - Return `meta` from `apply` to pass data to subsequent modules via the environment.
352
- - Set `local: true` on the module object if it runs on the local machine instead of over SSH.
353
-
354
- ## SshConnection API
355
-
356
- Methods available on the `ssh` parameter in custom modules:
357
-
358
- | Method | Returns | Description |
359
- | ----------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- |
360
- | `exec(cmd, options?)` | `Promise<ExecResult>` | Run a command. Returns `{ code, stdout, stderr }`. Throws on non-zero exit unless `ignoreExitCode: true`. |
361
- | `test(cmd)` | `Promise<boolean>` | Run a command, return `true` if exit code is 0. |
362
- | `output(cmd)` | `Promise<string>` | Run a command, return trimmed stdout. |
363
- | `lines(cmd)` | `Promise<string[]>` | Run a command, return stdout split into lines. |
364
- | `exists(path)` | `Promise<boolean>` | Check if a remote path exists. |
365
- | `readFile(path)` | `Promise<string>` | Read a remote file. |
366
- | `writeFile(path, content)` | `Promise<void>` | Write content to a remote file. |
367
- | `uploadFile(local, remote)` | `Promise<void>` | Upload a local file via SFTP. |
368
- | `downloadFile(remote, local)` | `Promise<void>` | Download a remote file. |
369
- | `sha256(path)` | `Promise<string \| null>` | Get SHA-256 hex digest, or `null` if the file does not exist. |
370
-
371
- Additional methods (`addPort`, `disconnect`, `getConnectionInfo`, `probeSudo`, `updateHost`) are available for advanced use cases. See the type definitions for details.
372
-
373
- ## Built-in helpers
374
-
375
- Import these from `"paratix"`:
376
-
377
- | Helper | Description |
378
- | ------------------------------ | ------------------------------------------------------------------------------------------------- |
379
- | `recipe(name, modules, opts?)` | Group modules into a reusable unit with optional signals. See [Recipes](#recipe) above. |
380
- | `assert(condition, message)` | Abort the run if `condition` returns false. The condition receives the current environment. |
381
- | `when(condition, ...modules)` | Run modules only if `condition` returns true. Skipped modules report `"skipped"`, not `"failed"`. |
382
- | `debug(message)` | Print a debug message during the run. |
383
- | `fail(message)` | Abort the run unconditionally with a failure. |
384
- | `pause(message?)` | Wait for the operator to press Enter before continuing. |
385
- | `shellQuote(value)` | Safely quote a string for shell interpolation. |
386
- | `NEEDS_APPLY` | Constant to return from `check` when work is needed. |
387
-
388
- ## LLM Guide
389
-
390
- This package includes an `llm-guide.md` file that provides detailed information for writing Paratix modules and playbooks. It covers the complete API reference, code patterns, and common mistakes to avoid. When using an LLM to generate Paratix code, point it at this file for best results.
391
-
392
- ## Integration tests
393
-
394
- Paratix ships a separate integration test entry point for real SSH/SFTP checks:
395
-
396
- ```bash
397
- pnpm --filter paratix test:integration
398
- ```
399
-
400
- For the full workspace review path including integration coverage, use:
401
-
402
- ```bash
403
- pnpm agent:check:integration
102
+ --dry-run
103
+ --env <key=value>
104
+ --env-file <path>
105
+ --first-run
106
+ --reconnect-timeout <seconds>
107
+ --verbose
108
+ --version
109
+ --help
404
110
  ```
405
111
 
406
- These tests are intentionally not part of the default unit test run. They require:
407
-
408
- - on macOS: `colima` plus a working Docker CLI connected to the active Colima runtime
409
- - on Linux/CI: a reachable Docker runtime
410
-
411
- The integration suite starts a temporary SSH test container, runs the tests against it and removes the container again afterwards. If `colima` is missing, the suite aborts with a clear error message instead of hanging or silently skipping coverage.
112
+ `--first-run` is meant for explicit bootstrap flows where a fresh server must be hardened first and the rest of the system should only be applied later.
412
113
 
413
- The suite now verifies real remote end state for core modules such as
414
- `file.directory`, `file.copy`, `file.template`, `command.shell`, `download.url`,
415
- and `download.large`, including ownership, mode, content, large-download flags,
416
- and idempotent `check()` behavior against a live server.
114
+ ## Documentation
417
115
 
418
- An example GitHub Actions workflow is included as a disabled template in
419
- `.github/workflows/integration-check.yml.disabled`. Rename it to `.yml` when
420
- you want the integration path to run on GitHub.
116
+ - For project scaffolding, see [`create-paratix`](https://www.npmjs.com/package/create-paratix).
117
+ - For detailed authoring guidance and module reference inside this repo, see [llm-guide.md](./llm-guide.md).
421
118
 
422
119
  ## License
423
120
 
424
- MIT
121
+ MIT — Copyright 2026 [Sebastian Software GmbH](https://sebastian-software.com)
package/dist/cli.js CHANGED
@@ -695,7 +695,7 @@ async function loadServerDefinitionFromFile(file, options) {
695
695
  return definition;
696
696
  }
697
697
  var program = new Command();
698
- program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.1.0");
698
+ program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.3.0");
699
699
  program.command("apply <file>").description("Apply a server definition").option(
700
700
  "--dry-run",
701
701
  "Only check, do not apply. Some modules validate prospective config but cannot verify runtime restarts.",
@@ -707,7 +707,7 @@ program.command("apply <file>").description("Apply a server definition").option(
707
707
  DEFAULT_RECONNECT_TIMEOUT_SECONDS
708
708
  ).option("--verbose", "Show full stack traces on error", false).action(async (file, options) => {
709
709
  try {
710
- printCliHeader("0.1.0");
710
+ printCliHeader("0.3.0");
711
711
  const environmentOverrides = applyCliEnvironmentOverrides(
712
712
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Commander options typed as Record<string, unknown>
713
713
  options.env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paratix",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Idempotent VPS setup tool in TypeScript",
5
5
  "type": "module",
6
6
  "files": [