paratix 0.0.1 → 0.1.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.
- package/README.md +81 -3
- package/dist/chunk-DUIGEB2J.js +439 -0
- package/dist/chunk-DUIGEB2J.js.map +1 -0
- package/dist/chunk-G3BMCQKU.js +1706 -0
- package/dist/chunk-G3BMCQKU.js.map +1 -0
- package/dist/chunk-ULJMW23T.js +4961 -0
- package/dist/chunk-ULJMW23T.js.map +1 -0
- package/dist/cli.d.ts +62 -0
- package/dist/cli.js +779 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +201 -0
- package/dist/index.js +620 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/index.d.ts +1332 -0
- package/dist/modules/index.js +56 -0
- package/dist/modules/index.js.map +1 -0
- package/dist/types-BPzPHfax.d.ts +252 -0
- package/llm-guide.md +607 -0
- package/package.json +35 -2
package/llm-guide.md
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
# Paratix -- LLM Code Guide
|
|
2
|
+
|
|
3
|
+
> This file helps LLMs write correct Paratix code. Read this before generating playbooks or modules.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Paratix is a CLI tool for idempotent VPS configuration via SSH using TypeScript playbooks. Each playbook exports a `server()` definition containing an ordered list of modules that are checked and applied over SSH. Modules follow a check/apply pattern: `check` determines if work is needed, `apply` enforces the desired state.
|
|
8
|
+
|
|
9
|
+
## Imports
|
|
10
|
+
|
|
11
|
+
Paratix has exactly two import paths:
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// Core API
|
|
15
|
+
import {
|
|
16
|
+
server,
|
|
17
|
+
recipe,
|
|
18
|
+
assert,
|
|
19
|
+
debug,
|
|
20
|
+
fail,
|
|
21
|
+
firstRun,
|
|
22
|
+
pause,
|
|
23
|
+
resolveEnvironment,
|
|
24
|
+
signals,
|
|
25
|
+
when,
|
|
26
|
+
shellQuote,
|
|
27
|
+
NEEDS_APPLY,
|
|
28
|
+
failed,
|
|
29
|
+
meta,
|
|
30
|
+
} from "paratix"
|
|
31
|
+
|
|
32
|
+
// Types (only when needed)
|
|
33
|
+
import type {
|
|
34
|
+
Module,
|
|
35
|
+
EnvironmentMetaEntry,
|
|
36
|
+
ModuleMetaEntry,
|
|
37
|
+
ModuleResult,
|
|
38
|
+
ServerDefinition,
|
|
39
|
+
SshConnection,
|
|
40
|
+
SshConfig,
|
|
41
|
+
Environment,
|
|
42
|
+
EnvironmentValue,
|
|
43
|
+
ExecResult,
|
|
44
|
+
ExecOptions,
|
|
45
|
+
} from "paratix"
|
|
46
|
+
|
|
47
|
+
// Built-in modules
|
|
48
|
+
import {
|
|
49
|
+
apt,
|
|
50
|
+
archive,
|
|
51
|
+
command,
|
|
52
|
+
cron,
|
|
53
|
+
download,
|
|
54
|
+
file,
|
|
55
|
+
git,
|
|
56
|
+
group,
|
|
57
|
+
hostname,
|
|
58
|
+
mount,
|
|
59
|
+
op,
|
|
60
|
+
package as pkg,
|
|
61
|
+
releaseUpgrade,
|
|
62
|
+
rsync,
|
|
63
|
+
service,
|
|
64
|
+
ssh,
|
|
65
|
+
sshd,
|
|
66
|
+
sysctl,
|
|
67
|
+
system,
|
|
68
|
+
systemd,
|
|
69
|
+
ufw,
|
|
70
|
+
user,
|
|
71
|
+
} from "paratix/modules"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Playbook Structure
|
|
75
|
+
|
|
76
|
+
A playbook is a TypeScript file with a default export of `server()`:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { server, recipe, when, shellQuote } from "paratix"
|
|
80
|
+
import { package as pkg, file, service, ufw, hostname } from "paratix/modules"
|
|
81
|
+
|
|
82
|
+
export default server({
|
|
83
|
+
// Required fields
|
|
84
|
+
name: "web-01",
|
|
85
|
+
host: "10.0.0.1",
|
|
86
|
+
ssh: {
|
|
87
|
+
user: "root",
|
|
88
|
+
ports: [22], // Array -- runner tries each port in order
|
|
89
|
+
privateKey: "~/.ssh/id_ed25519", // Optional -- "~" is expanded; omit to use SSH agent
|
|
90
|
+
// Optional:
|
|
91
|
+
// agentForward: false, // Forward SSH agent to remote
|
|
92
|
+
// passwordFallback: false,
|
|
93
|
+
// sudoPassword: "...",
|
|
94
|
+
// reconnectTimeout: 30000,
|
|
95
|
+
// strictHostKeyChecking: "yes", // default; set "accept-new" only for explicit TOFU
|
|
96
|
+
// expectedHostFingerprint: "SHA256:...",
|
|
97
|
+
// expectedHostPublicKey: "ssh-ed25519 AAAA...",
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// Optional: env values available in templates and conditions
|
|
101
|
+
env: {
|
|
102
|
+
DOMAIN: "example.com",
|
|
103
|
+
APP_PORT: 3000,
|
|
104
|
+
SECRET: async () => fetchFromVault("secret"), // Lazy async values supported
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// Required: ordered list of modules to apply
|
|
108
|
+
run: [
|
|
109
|
+
hostname.set("web-01"),
|
|
110
|
+
pkg.update("2025-03-01"),
|
|
111
|
+
pkg.installed("nginx", "curl"),
|
|
112
|
+
file.template("/etc/nginx/sites-available/default", "./files/nginx.conf.tmpl"),
|
|
113
|
+
service.enabled("nginx"),
|
|
114
|
+
service.running("nginx"),
|
|
115
|
+
ufw.enabled(),
|
|
116
|
+
ufw.rule("allow", 80),
|
|
117
|
+
ufw.rule("allow", 443),
|
|
118
|
+
|
|
119
|
+
// Conditional module
|
|
120
|
+
when((env) => env["DEPLOY_ENV"] === "production", service.enabled("fail2ban")),
|
|
121
|
+
],
|
|
122
|
+
|
|
123
|
+
// Optional: signals fire when ANY module in run reported "changed"
|
|
124
|
+
signals: [service.reload("nginx")],
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Module Reference
|
|
129
|
+
|
|
130
|
+
### `apt`
|
|
131
|
+
|
|
132
|
+
| Method | Signature | Idempotent |
|
|
133
|
+
| ----------------- | ---------------------------------------------------------------------------------------- | -------------------- |
|
|
134
|
+
| `apt.debconf` | `(packageName: string, selections: Record<string, string>): Module` | Yes |
|
|
135
|
+
| `apt.distUpgrade` | `(date: string): Module` | Yes (versioned flag) |
|
|
136
|
+
| `apt.key` | `(name: string, url: string, options: { fingerprint: string }): Module` | Yes |
|
|
137
|
+
| `apt.repository` | `(nameOrPpa: string, source?: string, options?: { signedBy?: false \| string }): Module` | Yes |
|
|
138
|
+
|
|
139
|
+
### `archive`
|
|
140
|
+
|
|
141
|
+
| Method | Signature | Idempotent |
|
|
142
|
+
| ----------------- | ----------------------------------------------------------------------------------------------- | ----------------- |
|
|
143
|
+
| `archive.extract` | `(source: string, destination: string, options?: { owner?: string; upload?: boolean }): Module` | Yes (SHA256 flag) |
|
|
144
|
+
|
|
145
|
+
### `command`
|
|
146
|
+
|
|
147
|
+
| Method | Signature | Idempotent |
|
|
148
|
+
| --------------- | -------------------------------------------------------------------- | ----------------- |
|
|
149
|
+
| `command.shell` | `(cmd: string, options?: { check?: string; name?: string }): Module` | Only with `check` |
|
|
150
|
+
|
|
151
|
+
### `cron`
|
|
152
|
+
|
|
153
|
+
| Method | Signature | Idempotent |
|
|
154
|
+
| ---------- | ----------------------------------------------------------------------------------------------- | ---------- |
|
|
155
|
+
| `cron.job` | `(user: string, name: string, options: { job: string; state?: "absent" \| "present" }): Module` | Yes |
|
|
156
|
+
|
|
157
|
+
### `download`
|
|
158
|
+
|
|
159
|
+
| Method | Signature | Idempotent |
|
|
160
|
+
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
161
|
+
| `download.url` | `(destination: string, url: string, options?: { force?: boolean; headers?: Record<string, string>; sha256?: string; mode?: string; owner?: string; group?: string }): Module` | Yes |
|
|
162
|
+
| `download.github` | `(destination: string, options: { repo: string; tag: string; asset: string; token?: string; sha256?: string; mode?: string; owner?: string; group?: string }): Module` | Yes |
|
|
163
|
+
| `download.large` | `(destination: string, url: string, options?: { group?: string; headers?: Record<string, string>; mode?: string; owner?: string; sha256?: string }): Module` | Yes (flag) |
|
|
164
|
+
|
|
165
|
+
### `file`
|
|
166
|
+
|
|
167
|
+
| Method | Signature | Idempotent |
|
|
168
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------- |
|
|
169
|
+
| `file.absent` | `(remotePath: string): Module` | Yes |
|
|
170
|
+
| `file.assemble` | `(remotePath: string, fragments: string[], options?: { mode?: string; owner?: string }): Module` | Yes |
|
|
171
|
+
| `file.block` | `(remotePath: string, options: { content: string; name: string; prefix?: string }): Module` | Yes |
|
|
172
|
+
| `file.copy` | `(remotePath: string, localPath: string, options?: { mode?: string; owner?: string }): Module` | Yes |
|
|
173
|
+
| `file.directory` | `(remotePath: string, options?: { mode?: string; owner?: string }): Module` | Yes |
|
|
174
|
+
| `file.line` | `(remotePath: string, line: string, options?: { match?: string }): Module` | Yes |
|
|
175
|
+
| `file.properties` | `(remotePath: string, options: { group?: string; mode?: string; owner?: string }): Module` | Yes |
|
|
176
|
+
| `file.replace` | `(remotePath: string, pattern: string, replacement: string): Module` | Yes |
|
|
177
|
+
| `file.stat` | `(remotePath: string): Module` | No (always-applies) |
|
|
178
|
+
| `file.template` | `(remotePath: string, templatePath: string, options?: { mode?: string; owner?: string; strict?: boolean }): Module` | Yes |
|
|
179
|
+
|
|
180
|
+
### `git`
|
|
181
|
+
|
|
182
|
+
| Method | Signature | Idempotent |
|
|
183
|
+
| ----------- | ------------------------------------------------------------------------- | ---------- |
|
|
184
|
+
| `git.clone` | `(repo: string, destination: string, options?: { ref?: string }): Module` | Yes |
|
|
185
|
+
|
|
186
|
+
### `group`
|
|
187
|
+
|
|
188
|
+
| Method | Signature | Idempotent |
|
|
189
|
+
| --------------- | ---------------------------------------------------- | ---------- |
|
|
190
|
+
| `group.present` | `(name: string, options?: { gid?: number }): Module` | Yes |
|
|
191
|
+
| `group.absent` | `(name: string): Module` | Yes |
|
|
192
|
+
|
|
193
|
+
### `hostname`
|
|
194
|
+
|
|
195
|
+
| Method | Signature | Idempotent |
|
|
196
|
+
| -------------- | ------------------------ | ---------- |
|
|
197
|
+
| `hostname.set` | `(name: string): Module` | Yes |
|
|
198
|
+
|
|
199
|
+
### `mount`
|
|
200
|
+
|
|
201
|
+
| Method | Signature | Idempotent |
|
|
202
|
+
| --------------- | --------------------------------------------------------------------------------------------------- | ---------- |
|
|
203
|
+
| `mount.present` | `(options: { fstype: string; opts: string; path: string; persist?: boolean; src: string }): Module` | Yes |
|
|
204
|
+
| `mount.absent` | `(options: { path: string; persist?: boolean }): Module` | Yes |
|
|
205
|
+
|
|
206
|
+
### `op`
|
|
207
|
+
|
|
208
|
+
| Method | Signature | Idempotent |
|
|
209
|
+
| ------------ | ---------------------------------------------- | --------------------------------- |
|
|
210
|
+
| `op.resolve` | `(references: Record<string, string>): Module` | No (always-applies, runs locally) |
|
|
211
|
+
|
|
212
|
+
### `package`
|
|
213
|
+
|
|
214
|
+
Import with renaming: `import { package as pkg } from "paratix/modules"`. The word `package` is reserved in JavaScript, so you must alias it.
|
|
215
|
+
|
|
216
|
+
| Method | Signature | Idempotent |
|
|
217
|
+
| ------------------- | --------------------------------- | -------------------- |
|
|
218
|
+
| `package.installed` | `(...packages: string[]): Module` | Yes |
|
|
219
|
+
| `package.absent` | `(...packages: string[]): Module` | Yes |
|
|
220
|
+
| `package.update` | `(date: string): Module` | Yes (versioned flag) |
|
|
221
|
+
| `package.upgrade` | `(date: string): Module` | Yes (versioned flag) |
|
|
222
|
+
|
|
223
|
+
### `releaseUpgrade`
|
|
224
|
+
|
|
225
|
+
| Method | Signature | Idempotent |
|
|
226
|
+
| ------------------------ | ------------------------------------------------------------------------------- | ---------- |
|
|
227
|
+
| `releaseUpgrade.upgrade` | `(options?: { dryRun?: boolean; resolveHost?: () => Promise<string> }): Module` | Yes |
|
|
228
|
+
|
|
229
|
+
### `rsync`
|
|
230
|
+
|
|
231
|
+
| Method | Signature | Idempotent |
|
|
232
|
+
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
233
|
+
| `rsync.sync` | `(options: { src: string; dest: string; chmod?: string; delete?: boolean; exclude?: string[]; group?: string; include?: string[]; owner?: string; strictHostKeyChecking?: "accept-new" \| "no" \| "off" \| "yes" }): Module` | Yes |
|
|
234
|
+
|
|
235
|
+
When the active Paratix SSH session already verified the host via `ssh.expectedHostFingerprint`
|
|
236
|
+
or `ssh.expectedHostPublicKey`, `rsync.sync()` reuses that verified host key for the external
|
|
237
|
+
rsync SSH process and does not depend on a local `known_hosts` entry.
|
|
238
|
+
|
|
239
|
+
### `service`
|
|
240
|
+
|
|
241
|
+
| Method | Signature | Idempotent |
|
|
242
|
+
| ------------------ | ------------------------ | --------------------------------------------------------- |
|
|
243
|
+
| `service.running` | `(name: string): Module` | Yes |
|
|
244
|
+
| `service.stopped` | `(name: string): Module` | Yes |
|
|
245
|
+
| `service.enabled` | `(name: string): Module` | Yes |
|
|
246
|
+
| `service.disabled` | `(name: string): Module` | Yes |
|
|
247
|
+
| `service.restart` | `(name: string): Module` | No (always-applies, use as signal) |
|
|
248
|
+
| `service.reload` | `(name: string): Module` | No (always-applies, use as signal) |
|
|
249
|
+
| `service.facts` | `(): Module` | No (always-applies, collects facts into `service.*` meta) |
|
|
250
|
+
|
|
251
|
+
### `ssh`
|
|
252
|
+
|
|
253
|
+
| Method | Signature | Idempotent |
|
|
254
|
+
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
255
|
+
| `ssh.authorizedKeys` | `(user: string, key: string, options?: { state?: "absent" \| "present" }): Module` | Yes |
|
|
256
|
+
| `ssh.knownHosts` | `(host: string, options?: { expectedFingerprint?: string; port?: number; publicKey?: string; state?: "absent" \| "present" }): Module` | Yes |
|
|
257
|
+
|
|
258
|
+
### `sshd`
|
|
259
|
+
|
|
260
|
+
| Method | Signature | Idempotent |
|
|
261
|
+
| ------------- | -------------------------------------------- | ---------------------------------- |
|
|
262
|
+
| `sshd.config` | `(settings: Record<string, string>): Module` | Yes |
|
|
263
|
+
| `sshd.port` | `(targetPort: number): Module` | Yes (emits typed `sshd.port` meta) |
|
|
264
|
+
|
|
265
|
+
### `sysctl`
|
|
266
|
+
|
|
267
|
+
| Method | Signature | Idempotent |
|
|
268
|
+
| ------------ | ----------------------------------------------------------------------------------- | ---------- |
|
|
269
|
+
| `sysctl.set` | `(key: string, value: string, options?: { state?: "absent" \| "present" }): Module` | Yes |
|
|
270
|
+
|
|
271
|
+
### `system`
|
|
272
|
+
|
|
273
|
+
| Method | Signature | Idempotent |
|
|
274
|
+
| --------------- | ------------------------------------------------------------- | --------------------------------------------------- |
|
|
275
|
+
| `system.facts` | `(): Module` | No (always-applies, emits typed `env` meta entries) |
|
|
276
|
+
| `system.reboot` | `(options?: { resolveHost?: () => Promise<string> }): Module` | No (always-applies) |
|
|
277
|
+
| `system.uptime` | `(): Module` | No (always-applies, emits typed `env` meta entries) |
|
|
278
|
+
|
|
279
|
+
### `systemd`
|
|
280
|
+
|
|
281
|
+
| Method | Signature | Idempotent |
|
|
282
|
+
| ---------------------- | ----------------------------------------- | ---------------------------------- |
|
|
283
|
+
| `systemd.unit` | `(name: string, content: string): Module` | Yes |
|
|
284
|
+
| `systemd.daemonReload` | `(): Module` | No (always-applies, use as signal) |
|
|
285
|
+
| `systemd.masked` | `(name: string): Module` | Yes |
|
|
286
|
+
| `systemd.unmasked` | `(name: string): Module` | Yes |
|
|
287
|
+
|
|
288
|
+
### `ufw`
|
|
289
|
+
|
|
290
|
+
| Method | Signature | Idempotent |
|
|
291
|
+
| ------------- | ---------------------------------------------------------------- | ---------- |
|
|
292
|
+
| `ufw.enabled` | `(): Module` | Yes |
|
|
293
|
+
| `ufw.rule` | `(action: "allow" \| "deny", ports: number \| number[]): Module` | Yes |
|
|
294
|
+
|
|
295
|
+
### `user`
|
|
296
|
+
|
|
297
|
+
| Method | Signature | Idempotent |
|
|
298
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
299
|
+
| `user.present` | `(name: string, options?: { uid?: number; shell?: string; home?: string; groups?: string[]; password?: string }): Module` | Yes |
|
|
300
|
+
| `user.absent` | `(name: string, options?: { removeHome?: boolean }): Module` | Yes |
|
|
301
|
+
|
|
302
|
+
## Custom Modules
|
|
303
|
+
|
|
304
|
+
A custom module must implement `check` and `apply`, both async:
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
import type { Module, ModuleResult, SshConnection, Environment } from "paratix"
|
|
308
|
+
import { NEEDS_APPLY, failed, meta } from "paratix"
|
|
309
|
+
|
|
310
|
+
function myCustomModule(configPath: string, content: string): Module {
|
|
311
|
+
return {
|
|
312
|
+
name: `my-module: ${configPath}`,
|
|
313
|
+
|
|
314
|
+
async check(ssh: SshConnection | null, env: Environment): Promise<"needs-apply" | "ok"> {
|
|
315
|
+
if (!ssh) return NEEDS_APPLY
|
|
316
|
+
const exists = await ssh.exists(configPath)
|
|
317
|
+
if (!exists) return NEEDS_APPLY
|
|
318
|
+
const current = await ssh.readFile(configPath)
|
|
319
|
+
return current === content ? "ok" : NEEDS_APPLY
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
async apply(ssh: SshConnection | null, env: Environment): Promise<ModuleResult> {
|
|
323
|
+
if (!ssh) return failed(`[my-module: ${configPath}] SSH connection is required`)
|
|
324
|
+
await ssh.writeFile(configPath, content)
|
|
325
|
+
return {
|
|
326
|
+
meta: [meta.env("MY_MODULE_PATH", configPath)],
|
|
327
|
+
status: "changed",
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Key rules for custom modules
|
|
335
|
+
|
|
336
|
+
- Always check `if (!ssh) return NEEDS_APPLY` in check and return `failed("...")` with a useful message in apply.
|
|
337
|
+
- Prefer `failedCommand("...", result)` when you used `ssh.exec(..., { ignoreExitCode: true })` and want stdout/stderr preserved for central runner output.
|
|
338
|
+
- Return `NEEDS_APPLY` (the exported constant), never the string literal `"needs-apply"`.
|
|
339
|
+
- `ModuleResult.status` must be one of: `"changed"`, `"failed"`, `"ok"`, `"skipped"`.
|
|
340
|
+
- Use typed `meta` entries in the return value, not loose objects.
|
|
341
|
+
- For normal downstream environment propagation, use `meta.env(name, value)`.
|
|
342
|
+
- `meta.env(...)` accepts strings, numbers, booleans, sync lazy functions, and async lazy functions.
|
|
343
|
+
- `meta.env(...)` is normalized internally to an async resolver, so downstream code should treat propagated environment values as lazily async and resolve them via `resolveEnvironment(...)` when it needs the concrete primitive.
|
|
344
|
+
- Use dedicated built-in meta entries only for runner control-plane effects, for example `meta.sshdPort(...)`, `meta.systemHost(...)`, and `meta.systemReboot()`.
|
|
345
|
+
- Use the exported guards such as `isEnvironmentMetaEntry(...)`, `isStringEnvironmentMetaEntry(...)`, `isNumberEnvironmentMetaEntry(...)`, `isBooleanEnvironmentMetaEntry(...)`, `isLazyEnvironmentMetaEntry(...)`, `isSshdPortMetaEntry(...)`, `isSystemHostMetaEntry(...)`, and `isSystemRebootMetaEntry(...)` when you need to inspect meta entries safely.
|
|
346
|
+
- Set `local: true` on the module object if it runs on the local machine (ssh will be `null`).
|
|
347
|
+
|
|
348
|
+
### SshConnection API
|
|
349
|
+
|
|
350
|
+
Methods available on the `ssh` parameter:
|
|
351
|
+
|
|
352
|
+
| Method | Return type | Description |
|
|
353
|
+
| --------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
354
|
+
| `ssh.exec(cmd, options?)` | `Promise<ExecResult>` | Run command, get `{ code, stdout, stderr }`. Throws on non-zero unless `ignoreExitCode: true`. |
|
|
355
|
+
| `ssh.test(cmd)` | `Promise<boolean>` | Run command, return `true` if exit code is 0. |
|
|
356
|
+
| `ssh.output(cmd)` | `Promise<string>` | Run command, return trimmed stdout. |
|
|
357
|
+
| `ssh.lines(cmd)` | `Promise<string[]>` | Run command, return stdout split into lines. |
|
|
358
|
+
| `ssh.exists(path)` | `Promise<boolean>` | Check if remote path exists. |
|
|
359
|
+
| `ssh.readFile(path)` | `Promise<string>` | Read remote file content. |
|
|
360
|
+
| `ssh.writeFile(path, content)` | `Promise<void>` | Write content to remote file. |
|
|
361
|
+
| `ssh.uploadFile(local, remote)` | `Promise<void>` | Upload local file via SFTP. |
|
|
362
|
+
| `ssh.downloadFile(remote, local)` | `Promise<void>` | Download remote file. |
|
|
363
|
+
| `ssh.sha256(path)` | `Promise<string \| null>` | Get SHA-256 hex digest, or null if not found. |
|
|
364
|
+
| `ssh.addPort(port)` | `void` | Register an additional port opened on the remote host (advanced). |
|
|
365
|
+
| `ssh.disconnect()` | `void` | Close the SSH connection. |
|
|
366
|
+
| `ssh.getConnectionInfo()` | `{ host, port, privateKeyPath?, user }` | Return current connection parameters. |
|
|
367
|
+
| `ssh.probeSudo()` | `Promise<void>` | Probe/cache sudo access; prompts interactively if needed. |
|
|
368
|
+
| `ssh.updateHost(host)` | `void` | Update the target host address (e.g., after IP change). |
|
|
369
|
+
|
|
370
|
+
### ExecOptions
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
{
|
|
374
|
+
env?: Record<string, string> // Extra environment variables
|
|
375
|
+
ignoreExitCode?: boolean // Don't throw on non-zero exit
|
|
376
|
+
secrets?: string[] // Strings to mask in error output
|
|
377
|
+
silent?: boolean // Suppress stdout/stderr
|
|
378
|
+
timeout?: number // Abort after N milliseconds
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Template System
|
|
383
|
+
|
|
384
|
+
Files deployed via `file.template(remotePath, localTemplatePath)` can contain `{{KEY}}` placeholders.
|
|
385
|
+
|
|
386
|
+
- Placeholders are resolved at runtime from the `env` object of `server()` and from typed `meta.env(...)` entries returned by previous modules.
|
|
387
|
+
- Environment values can be strings, numbers, booleans, sync lazy functions, or async lazy functions.
|
|
388
|
+
- Values propagated through `meta.env(...)` are normalized to async resolution before downstream modules or templates consume them.
|
|
389
|
+
- Escaping: `\{{` produces a literal `{{` in the output.
|
|
390
|
+
- Unknown keys throw an error at runtime.
|
|
391
|
+
- **Modifiers:** `{{KEY|shell}}` applies `shellQuote()` to the value. This is the only built-in modifier.
|
|
392
|
+
- **Strict mode (default: on).** Every placeholder must have an explicit modifier (`|shell` or `|raw`). Bare `{{KEY}}` placeholders throw at render time. Pass `strict: false` in the options to disable this check.
|
|
393
|
+
- **Security: No default escaping.** Values are inserted verbatim. If the template produces a shell script or shell config, **always** use `{{KEY|shell}}` for every variable — omitting `|shell` can lead to shell injection.
|
|
394
|
+
|
|
395
|
+
Example template file (`nginx.conf.tmpl`):
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
server {
|
|
399
|
+
listen 80;
|
|
400
|
+
server_name {{DOMAIN|raw}};
|
|
401
|
+
proxy_pass http://127.0.0.1:{{APP_PORT|raw}};
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
With `env: { DOMAIN: "example.com", APP_PORT: 3000 }` in the server definition.
|
|
406
|
+
|
|
407
|
+
## Recipes
|
|
408
|
+
|
|
409
|
+
A recipe groups modules into a named, reusable unit with optional signals:
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
import { recipe } from "paratix"
|
|
413
|
+
import { package as pkg, file, service } from "paratix/modules"
|
|
414
|
+
|
|
415
|
+
export const nginxRecipe = recipe(
|
|
416
|
+
"nginx",
|
|
417
|
+
[
|
|
418
|
+
pkg.installed("nginx"),
|
|
419
|
+
file.template("/etc/nginx/nginx.conf", "./files/nginx.conf.tmpl"),
|
|
420
|
+
service.enabled("nginx"),
|
|
421
|
+
service.running("nginx"),
|
|
422
|
+
],
|
|
423
|
+
{
|
|
424
|
+
signals: [service.reload("nginx")],
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
**How recipes work:**
|
|
430
|
+
|
|
431
|
+
- Modules run in order; execution stops on first `"failed"` status.
|
|
432
|
+
- `meta.env(...)` values propagate from one module to all subsequent ones within the recipe and resolve lazily when later modules or templates consume them.
|
|
433
|
+
- If any module reports `"changed"`, the `signals` array fires after all modules complete.
|
|
434
|
+
- `signals.flush()` can be used inside the same scope to execute currently pending signals early.
|
|
435
|
+
- Recipes can be nested: include a recipe in another recipe's module list.
|
|
436
|
+
|
|
437
|
+
**When to use recipes:**
|
|
438
|
+
|
|
439
|
+
- Group related modules that form a logical unit (e.g., "install and configure nginx").
|
|
440
|
+
- When you need signals to fire only if that specific group changed (not the entire server run).
|
|
441
|
+
|
|
442
|
+
## Built-in Functions
|
|
443
|
+
|
|
444
|
+
### `assert(condition, message)`
|
|
445
|
+
|
|
446
|
+
Fail the run if a condition is not met.
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
assert((env) => !!env["APP_SECRET"], "APP_SECRET must be set")
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### `when(condition, ...modules)`
|
|
453
|
+
|
|
454
|
+
Conditionally run modules. Skipped modules report `"skipped"`, not `"failed"`.
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
when((env) => env["DEPLOY_ENV"] === "production", service.enabled("fail2ban"), ufw.enabled())
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### `debug(message)`
|
|
461
|
+
|
|
462
|
+
Print a debug message during apply. Always runs.
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
debug("Starting database setup")
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### `fail(message)`
|
|
469
|
+
|
|
470
|
+
Unconditionally abort the run with a failure.
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
fail("This branch should be unreachable")
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### `firstRun.stop(message?)`
|
|
477
|
+
|
|
478
|
+
Stoppt den aktuellen Lauf kontrolliert, wenn Paratix mit `--first-run` gestartet wurde. Nützlich als explizite Staging-Grenze in Bootstrap-Playbooks.
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
firstRun.stop("Bootstrap foundation complete; rerun without --first-run to continue.")
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### `signals.flush(message?)`
|
|
485
|
+
|
|
486
|
+
Führt alle aktuell offenen Signale des aktiven Scopes sofort aus und setzt deren Pending-Zustand zurück.
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
signals.flush("Reload services before the next bootstrap stage")
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### `pause(message?)`
|
|
493
|
+
|
|
494
|
+
Wait for operator to press Enter. Useful for interactive confirmation.
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
pause("Review changes above, then press Enter to continue")
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### `shellQuote(value)`
|
|
501
|
+
|
|
502
|
+
Safely quote a string for shell interpolation. Use this when building shell commands with dynamic values.
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
await ssh.exec(`cat ${shellQuote(filePath)}`)
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### `NEEDS_APPLY`
|
|
509
|
+
|
|
510
|
+
Constant for the check return value indicating work is needed. Always use this instead of the string `"needs-apply"`.
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
import { NEEDS_APPLY } from "paratix"
|
|
514
|
+
|
|
515
|
+
async check(ssh) {
|
|
516
|
+
if (!ssh) return NEEDS_APPLY
|
|
517
|
+
// ...
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
## Do's and Don'ts
|
|
522
|
+
|
|
523
|
+
### DO
|
|
524
|
+
|
|
525
|
+
1. Always use `export default server({...})` as the default export of a playbook.
|
|
526
|
+
2. Import modules from `"paratix/modules"`, not from `"paratix"`.
|
|
527
|
+
3. `package` must be aliased on import: `import { package as pkg } from "paratix/modules"` -- `package` is a reserved word in JavaScript.
|
|
528
|
+
4. For idempotency with `command.shell()`, always provide a `check` command.
|
|
529
|
+
5. Use `{{KEY|shell}}` or `{{KEY|raw}}` placeholders in `.tmpl` files — strict mode is on by default and bare `{{KEY}}` will throw. Provide values via `env` in `server()`.
|
|
530
|
+
6. Use `service.restart()` and `service.reload()` as `signals` in recipes, not directly in `run`.
|
|
531
|
+
7. Use `signals.flush()` only als expliziten Checkpoint, wenn gestufte Flows einen vorgezogenen Signal-Flush brauchen.
|
|
532
|
+
8. Always pass a date string to `package.upgrade()` and `package.update()` -- it is the idempotency key.
|
|
533
|
+
9. Specify `ssh.ports` as an array -- the runner tries each port in order.
|
|
534
|
+
10. Custom modules must implement both `check` and `apply`, both async.
|
|
535
|
+
11. Use `shellQuote()` when interpolating dynamic values into shell commands.
|
|
536
|
+
12. Emit downstream values via `meta.env(...)` and use dedicated built-in meta entries only for runner control-plane behavior.
|
|
537
|
+
13. When you need a concrete propagated value inside custom code, use `await resolveEnvironment(env, "KEY")` instead of assuming `env["KEY"]` is already a plain primitive.
|
|
538
|
+
|
|
539
|
+
### DON'T
|
|
540
|
+
|
|
541
|
+
1. Do NOT `import { package } from "paratix"` -- modules come from `"paratix/modules"`.
|
|
542
|
+
2. Do NOT use `service.restart()` directly in `run` -- it runs EVERY time. Use it as a signal in a `recipe()`.
|
|
543
|
+
3. Do NOT use the string literal `"needs-apply"` -- always use the exported constant `NEEDS_APPLY`.
|
|
544
|
+
4. Do NOT assume `ssh` is non-null in custom modules -- always check it and return `failed("...")` with context.
|
|
545
|
+
5. Do NOT forget that `package.upgrade("2025-01-15")` needs a date as IDEMPOTENCY KEY -- the date controls when the upgrade re-runs.
|
|
546
|
+
6. Do NOT interpolate `env` values directly in shell commands -- use `shellQuote()` for safe quoting.
|
|
547
|
+
7. Do NOT store module methods as variables and call them later -- modules are configured at creation time, not at call time.
|
|
548
|
+
8. Do NOT use `ssh.exec()` without `ignoreExitCode: true` when you need to inspect the exit code -- without it, a non-zero exit throws an exception.
|
|
549
|
+
9. Do NOT use template syntax `{{key}}` in TypeScript code -- templates are only for files rendered via `file.template()`.
|
|
550
|
+
10. Do NOT use `signals` on the top-level `server()` when you mean a recipe signal -- `server.signals` fire when ANY module in `run` changed.
|
|
551
|
+
11. Do NOT treat `signals.flush()` as a global queue flush -- it only affects the current scope.
|
|
552
|
+
12. `signals.flush()` flusht immer nur den aktuellen Scope:
|
|
553
|
+
- in einer Recipe deren Recipe-Signale
|
|
554
|
+
- auf Top-Level `server(...).signals`
|
|
555
|
+
13. Do NOT call `server()` without all required fields (`name`, `host`, `ssh`, `run`) -- it throws at construction time. `name` and `host` must not be empty strings.
|
|
556
|
+
14. Do NOT use empty arrays for `ssh.ports` or empty strings for `ssh.user`/`ssh.privateKey` -- validation rejects these. `ssh.privateKey` may be omitted entirely to use the SSH agent instead.
|
|
557
|
+
15. Do NOT return loose `meta: { ... }` maps from custom modules -- always use typed meta entries.
|
|
558
|
+
|
|
559
|
+
## Testing Patterns
|
|
560
|
+
|
|
561
|
+
Tests use vitest with a `createMockSsh` helper:
|
|
562
|
+
|
|
563
|
+
```typescript
|
|
564
|
+
import { describe, expect, it } from "vitest"
|
|
565
|
+
import { createMockSsh } from "./helpers/mockSsh.js"
|
|
566
|
+
import { NEEDS_APPLY } from "../src/types.js"
|
|
567
|
+
|
|
568
|
+
describe("myModule", () => {
|
|
569
|
+
it("should detect needs-apply when file is missing", async () => {
|
|
570
|
+
const ssh = createMockSsh({
|
|
571
|
+
// Map command strings to partial ExecResult { code?, stdout?, stderr? }
|
|
572
|
+
"[ -e '/etc/myconfig' ]": { code: 1 },
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
const mod = myCustomModule("/etc/myconfig", "desired content")
|
|
576
|
+
const result = await mod.check(ssh, {})
|
|
577
|
+
expect(result).toBe(NEEDS_APPLY)
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it("should apply changes", async () => {
|
|
581
|
+
const ssh = createMockSsh({})
|
|
582
|
+
|
|
583
|
+
const mod = myCustomModule("/etc/myconfig", "desired content")
|
|
584
|
+
const result = await mod.apply(ssh, {})
|
|
585
|
+
expect(result.status).toBe("changed")
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
it("should track executed commands", async () => {
|
|
589
|
+
const ssh = createMockSsh({})
|
|
590
|
+
|
|
591
|
+
const mod = myCustomModule("/etc/myconfig", "content")
|
|
592
|
+
await mod.apply(ssh, {})
|
|
593
|
+
|
|
594
|
+
// ssh.calls contains all commands executed in order
|
|
595
|
+
expect(ssh.calls).toContain("some-expected-command")
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
### `createMockSsh` behavior
|
|
601
|
+
|
|
602
|
+
- Accepts `Record<string, Partial<ExecResult>>` mapping command strings to responses.
|
|
603
|
+
- Default response: `{ code: 0, stdout: "", stderr: "" }`.
|
|
604
|
+
- `ssh.test(cmd)` returns `code === 0`.
|
|
605
|
+
- `ssh.exists(path)` delegates to `ssh.test("[ -e '<path>' ]")`.
|
|
606
|
+
- `ssh.readFile(path)` delegates to `ssh.output("cat '<path>'")`.
|
|
607
|
+
- Returns a `{ calls: string[] } & SshConnection` object -- `calls` records all commands in execution order.
|
package/package.json
CHANGED
|
@@ -1,8 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paratix",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Idempotent VPS setup tool in TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"llm-guide.md"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.js",
|
|
12
|
+
"./modules": "./dist/modules/index.js"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"paratix": "./dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"commander": "^13.1.0",
|
|
19
|
+
"picocolors": "^1.1.1",
|
|
20
|
+
"ssh2": "^1.16.0",
|
|
21
|
+
"tsx": "^4.19.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^25.5.0",
|
|
25
|
+
"@types/ssh2": "^1.15.4",
|
|
26
|
+
"tsup": "^8.4.0",
|
|
27
|
+
"typescript": "^5.8.0",
|
|
28
|
+
"vitest": "^3.1.0"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=24.0.0"
|
|
32
|
+
},
|
|
6
33
|
"keywords": [
|
|
7
34
|
"paratix",
|
|
8
35
|
"vps",
|
|
@@ -25,5 +52,11 @@
|
|
|
25
52
|
"url": "https://github.com/sebastian-software/paratix/issues"
|
|
26
53
|
},
|
|
27
54
|
"author": "Sebastian Fastner <s.fastner@sebastian-software.de>",
|
|
28
|
-
"license": "MIT"
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsup",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
60
|
+
"test:watch": "vitest"
|
|
61
|
+
}
|
|
29
62
|
}
|