paratix 0.9.0 → 0.12.2
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 +7 -1
- package/dist/chunk-YOSHYUST.js +19058 -0
- package/dist/chunk-YOSHYUST.js.map +1 -0
- package/dist/cli.js +5091 -224
- package/dist/cli.js.map +1 -1
- package/dist/{user-B9lkXr0X.d.ts → index-udpAybq3.d.ts} +653 -36
- package/dist/index.d.ts +51 -7
- package/dist/index.js +965 -73
- package/dist/index.js.map +1 -1
- package/dist/modules/index.d.ts +1 -42
- package/dist/modules/index.js +3 -2
- package/llm-guide.md +219 -38
- package/package.json +10 -8
- package/dist/chunk-47PTUZZR.js +0 -495
- package/dist/chunk-47PTUZZR.js.map +0 -1
- package/dist/chunk-7Y2RBVG4.js +0 -5891
- package/dist/chunk-7Y2RBVG4.js.map +0 -1
- package/dist/chunk-NRDLYHJL.js +0 -1866
- package/dist/chunk-NRDLYHJL.js.map +0 -1
- package/dist/cli.d.ts +0 -62
- package/dist/types-Cl2Muw1x.d.ts +0 -254
package/llm-guide.md
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
apt,
|
|
50
50
|
archive,
|
|
51
51
|
command,
|
|
52
|
+
compose,
|
|
52
53
|
cron,
|
|
53
54
|
download,
|
|
54
55
|
file,
|
|
@@ -56,10 +57,13 @@ import {
|
|
|
56
57
|
group,
|
|
57
58
|
hostname,
|
|
58
59
|
mount,
|
|
60
|
+
net,
|
|
59
61
|
op,
|
|
60
62
|
package as pkg,
|
|
63
|
+
quadlet,
|
|
61
64
|
releaseUpgrade,
|
|
62
65
|
rsync,
|
|
66
|
+
script,
|
|
63
67
|
service,
|
|
64
68
|
ssh,
|
|
65
69
|
sshd,
|
|
@@ -67,6 +71,7 @@ import {
|
|
|
67
71
|
sysctl,
|
|
68
72
|
system,
|
|
69
73
|
systemd,
|
|
74
|
+
timer,
|
|
70
75
|
ufw,
|
|
71
76
|
user,
|
|
72
77
|
} from "paratix/modules"
|
|
@@ -145,15 +150,20 @@ export default server({
|
|
|
145
150
|
|
|
146
151
|
### `command`
|
|
147
152
|
|
|
148
|
-
| Method | Signature
|
|
149
|
-
| --------------- |
|
|
150
|
-
| `command.shell` | `(cmd: string, options?: { check?: string; name?: string }): Module` | Only with `check` |
|
|
153
|
+
| Method | Signature | Idempotent |
|
|
154
|
+
| --------------- | ---------------------------------------------------------------------------------------- | ----------------- |
|
|
155
|
+
| `command.shell` | `(cmd: string, options?: { check?: string; name?: string; secrets?: string[] }): Module` | Only with `check` |
|
|
151
156
|
|
|
152
157
|
### `cron`
|
|
153
158
|
|
|
154
|
-
| Method
|
|
155
|
-
|
|
|
156
|
-
| `cron.job`
|
|
159
|
+
| Method | Signature | Idempotent |
|
|
160
|
+
| ------------- | ----------------------------------------------------------------------------------------------- | ---------- |
|
|
161
|
+
| `cron.job` | `(user: string, name: string, options: { job: string; state?: "absent" \| "present" }): Module` | Yes |
|
|
162
|
+
| `cron.absent` | `(user: string, name: string): Module` | Yes |
|
|
163
|
+
|
|
164
|
+
`cron.absent(user, name)` is the dedicated uninstall variant: it removes a
|
|
165
|
+
managed cron entry without requiring a placeholder `job` argument. Use it as
|
|
166
|
+
the idiomatic way to ensure a previously installed cron job is gone.
|
|
157
167
|
|
|
158
168
|
### `compose`
|
|
159
169
|
|
|
@@ -168,11 +178,22 @@ export default server({
|
|
|
168
178
|
|
|
169
179
|
### `download`
|
|
170
180
|
|
|
171
|
-
| Method | Signature
|
|
172
|
-
| ----------------- |
|
|
173
|
-
| `download.url` | `(destination: string, url: string, options?: { force?: boolean; headers?: Record<string, string>; sha256?: string; mode?: string; owner?: string; group?: string }): Module` | Yes |
|
|
174
|
-
| `download.github` | `(destination: string, options: { repo: string; tag: string; asset: string; token?: string; sha256?: string; mode?: string; owner?: string; group?: string }): Module`
|
|
175
|
-
| `download.large` | `(destination: string, url: string, options?: { group?: string; headers?: Record<string, string>; mode?: string; owner?: string; sha256?: string }): Module` | Yes (flag) |
|
|
181
|
+
| Method | Signature | Idempotent |
|
|
182
|
+
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
183
|
+
| `download.url` | `(destination: string, url: string, options?: { allowInsecureHttp?: boolean; allowInsecureHttpHeaders?: boolean; allowUnverifiedDownload?: boolean; connectTimeout?: number; force?: boolean; headers?: Record<string, string>; sha256?: string; mode?: string; owner?: string; group?: string; timeout?: number }): Module` | Yes |
|
|
184
|
+
| `download.github` | `(destination: string, options: { repo: string; tag: string; asset: string; token?: string; allowUnverifiedDownload?: boolean; connectTimeout?: number; sha256?: string; mode?: string; owner?: string; group?: string; timeout?: number }): Module` | Yes |
|
|
185
|
+
| `download.large` | `(destination: string, url: string, options?: { allowInsecureHttp?: boolean; allowInsecureHttpHeaders?: boolean; allowUnverifiedDownload?: boolean; connectTimeout?: number; group?: string; headers?: Record<string, string>; mode?: string; owner?: string; sha256?: string; timeout?: number }): Module` | Yes (flag) |
|
|
186
|
+
|
|
187
|
+
Downloads require an explicit integrity decision at runtime: provide `sha256`
|
|
188
|
+
for verification, or set `allowUnverifiedDownload: true` when the remote
|
|
189
|
+
artifact is intentionally trusted without a pinned digest. `download.url` and
|
|
190
|
+
`download.large` allow only HTTPS by default; set `allowInsecureHttp: true`
|
|
191
|
+
only when an `http://` source or redirect is intentional. Sensitive headers
|
|
192
|
+
such as `Authorization`, `Cookie`, and `X-Api-Key` are still rejected over
|
|
193
|
+
plaintext HTTP unless `allowInsecureHttpHeaders: true` is also set.
|
|
194
|
+
Curl-based downloads use bounded transfer timing by default (`connectTimeout:
|
|
195
|
+
10000`, `timeout: 300000`, both in milliseconds). Override these options for
|
|
196
|
+
very slow artifact hosts or large downloads.
|
|
176
197
|
|
|
177
198
|
### `file`
|
|
178
199
|
|
|
@@ -191,6 +212,10 @@ export default server({
|
|
|
191
212
|
| `file.stat` | `(remotePath: string): Module` | No (always-applies) |
|
|
192
213
|
| `file.template` | `(remotePath: string, templatePath: string, options?: { mode?: string; owner?: string; strict?: boolean }): Module` | Yes |
|
|
193
214
|
|
|
215
|
+
**Hinweis zu `file.copy` und Default-Mode:** Wenn `options.mode` weggelassen wird, setzt `file.copy` den Modus auf den dokumentierten Default `0644`. Der Modus wird sowohl beim Hochladen (`uploadFile` mit `{ mode }`) als auch in `check` gegen den Soll-Wert verglichen, damit nachfolgende Läufe Mode-Drift (z. B. manuelles `chmod 0600`) als `needs-apply` erkennen. Wer ein restriktiveres Recht braucht (z. B. für Secrets), übergibt explizit `{ mode: "0600" }`.
|
|
216
|
+
|
|
217
|
+
**Hinweis zu `file.line`:** Der `line`-Wert muss eine einzelne Zeile ohne CR/LF sein. Für mehrzeilige Inhalte `file.block` verwenden.
|
|
218
|
+
|
|
194
219
|
### `git`
|
|
195
220
|
|
|
196
221
|
| Method | Signature | Idempotent |
|
|
@@ -217,6 +242,21 @@ export default server({
|
|
|
217
242
|
| `mount.present` | `(options: { fstype: string; opts: string; path: string; persist?: boolean; src: string }): Module` | Yes |
|
|
218
243
|
| `mount.absent` | `(options: { path: string; persist?: boolean }): Module` | Yes |
|
|
219
244
|
|
|
245
|
+
### `net`
|
|
246
|
+
|
|
247
|
+
| Method | Signature | Idempotent |
|
|
248
|
+
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
249
|
+
| `net.hosts` | `(ip: string, hostnames: string[], options?: { state?: "absent" \| "present" }): Module` | Yes |
|
|
250
|
+
| `net.interface` | `(name: string, options: InterfaceOptions): Module` | Yes |
|
|
251
|
+
| `net.request` | `(url: string, options?: { allowInsecureHttpHeaders?: boolean; body?: string; connectTimeout?: number; headers?: Record<string, string>; method?: string; status?: number; timeout?: number }): Module` | Yes |
|
|
252
|
+
| `net.resolv` | `(options: { nameservers: string[]; search?: string[] }): Module` | Yes |
|
|
253
|
+
| `net.route` | `(destination: string, gateway: string, options?: { device?: string; state?: "absent" \| "present" }): Module` | Yes |
|
|
254
|
+
| `net.waitFor` | `(options: WaitForOptions): Module` | Yes |
|
|
255
|
+
|
|
256
|
+
`net.request` uses bounded curl timing by default (`connectTimeout: 10000`,
|
|
257
|
+
`timeout: 300000`, both in milliseconds). Override these values for endpoints
|
|
258
|
+
that are expected to respond more slowly.
|
|
259
|
+
|
|
220
260
|
### `op`
|
|
221
261
|
|
|
222
262
|
| Method | Signature | Idempotent |
|
|
@@ -263,9 +303,9 @@ apt.distUpgrade("2026-05-01", { timeout: 1_200_000 })
|
|
|
263
303
|
|
|
264
304
|
### `releaseUpgrade`
|
|
265
305
|
|
|
266
|
-
| Method | Signature
|
|
267
|
-
| ------------------------ |
|
|
268
|
-
| `releaseUpgrade.upgrade` | `(options?: { dryRun?: boolean; resolveHost?: () => Promise<string
|
|
306
|
+
| Method | Signature | Idempotent |
|
|
307
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------- | ---------- |
|
|
308
|
+
| `releaseUpgrade.upgrade` | `(options?: { dryRun?: boolean; resolveHost?: () => Promise<string>; timeout?: number }): Module` | Yes |
|
|
269
309
|
|
|
270
310
|
### `rsync`
|
|
271
311
|
|
|
@@ -277,6 +317,12 @@ When the active Paratix SSH session already verified the host via `ssh.expectedH
|
|
|
277
317
|
or `ssh.expectedHostPublicKey`, `rsync.sync()` reuses that verified host key for the external
|
|
278
318
|
rsync SSH process and does not depend on a local `known_hosts` entry.
|
|
279
319
|
|
|
320
|
+
### `script`
|
|
321
|
+
|
|
322
|
+
| Method | Signature | Idempotent |
|
|
323
|
+
| ------------- | -------------------------------------------------------------------------------------------- | -------------------- |
|
|
324
|
+
| `script.once` | `(name: string, localPath: string, options?: { args?: string[]; version?: string }): Module` | Yes (versioned flag) |
|
|
325
|
+
|
|
280
326
|
### `service`
|
|
281
327
|
|
|
282
328
|
| Method | Signature | Idempotent |
|
|
@@ -296,6 +342,8 @@ rsync SSH process and does not depend on a local `known_hosts` entry.
|
|
|
296
342
|
| `ssh.authorizedKeys` | `(user: string, key: string, options?: { state?: "absent" \| "present" }): Module` | Yes |
|
|
297
343
|
| `ssh.knownHosts` | `(host: string, options?: { expectedFingerprint?: string; port?: number; publicKey?: string; state?: "absent" \| "present" }): Module` | Yes |
|
|
298
344
|
|
|
345
|
+
**Trust-Anchor-Pflicht für `ssh.knownHosts` (state `present`):** Im Default-State `present` muss entweder `expectedFingerprint` oder `publicKey` gesetzt sein. Ohne Trust Anchor wirft der Modul-Konstruktor sofort, denn `check` würde sonst jeden vorhandenen `known_hosts`-Eintrag (auch ältere, möglicherweise kompromittierte TOFU-Akzeptanzen) als „ok" werten und den Anchor-Vergleich überspringen. Für `state: "absent"` ist kein Anchor nötig — dort wird der Eintrag ohnehin entfernt.
|
|
346
|
+
|
|
299
347
|
### `sshd`
|
|
300
348
|
|
|
301
349
|
| Method | Signature | Idempotent |
|
|
@@ -313,9 +361,9 @@ rsync SSH process and does not depend on a local `known_hosts` entry.
|
|
|
313
361
|
|
|
314
362
|
### `sysctl`
|
|
315
363
|
|
|
316
|
-
| Method | Signature
|
|
317
|
-
| ------------ |
|
|
318
|
-
| `sysctl.set` | `(key: string, value: string, options?: { state?: "absent" \| "present" }): Module` | Yes
|
|
364
|
+
| Method | Signature | Idempotent |
|
|
365
|
+
| ------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
366
|
+
| `sysctl.set` | `(key: string, value: string, options?: { resetValue?: string; state?: "absent" \| "present" }): Module` | Yes for `present`. For `absent`: only the persistence file is removed by default; pass `resetValue` to also restore the live runtime value. |
|
|
319
367
|
|
|
320
368
|
### `system`
|
|
321
369
|
|
|
@@ -334,6 +382,40 @@ rsync SSH process and does not depend on a local `known_hosts` entry.
|
|
|
334
382
|
| `systemd.masked` | `(name: string): Module` | Yes |
|
|
335
383
|
| `systemd.unmasked` | `(name: string): Module` | Yes |
|
|
336
384
|
|
|
385
|
+
### `timer`
|
|
386
|
+
|
|
387
|
+
| Method | Signature | Idempotent |
|
|
388
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
389
|
+
| `timer.scheduled` | `(name: string, options: { accuracySec?: number \| string; description?: string; environment?: Record<string, string>; exec: string; group?: string; onCalendar: string \| string[]; persistent?: boolean; randomizedDelaySec?: number \| string; state?: "absent" \| "present"; user?: string; workingDirectory?: string }): Module` | Yes |
|
|
390
|
+
| `timer.absent` | `(name: string): Module` | Yes |
|
|
391
|
+
|
|
392
|
+
`timer.absent(name)` is the dedicated uninstall variant: it disables and stops
|
|
393
|
+
the timer, removes both unit files, and reloads systemd, without requiring
|
|
394
|
+
placeholder `exec`/`onCalendar` values.
|
|
395
|
+
|
|
396
|
+
`timer.scheduled` is the systemd-timer equivalent of `cron.job`. It writes
|
|
397
|
+
`<name>.service` (`Type=oneshot`) and `<name>.timer` to `/etc/systemd/system/`,
|
|
398
|
+
reloads systemd, and runs `systemctl enable --now <name>.timer`. Use it as the
|
|
399
|
+
default for new scheduled tasks: `Persistent=true` is on by default so missed
|
|
400
|
+
runs are caught up after downtime, and logs land in journald (`journalctl -u
|
|
401
|
+
<name>.service`). Cron remains a fit for ad-hoc per-user crontab entries.
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
timer.scheduled("backup", {
|
|
405
|
+
exec: "/usr/local/bin/backup",
|
|
406
|
+
onCalendar: "*-*-* 03:00:00",
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
timer.scheduled("cleanup", {
|
|
410
|
+
exec: "/usr/local/bin/cleanup",
|
|
411
|
+
onCalendar: ["Mon..Fri 02:00", "Sat 04:00"],
|
|
412
|
+
user: "deploy",
|
|
413
|
+
randomizedDelaySec: 300,
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
timer.absent("legacy-task")
|
|
417
|
+
```
|
|
418
|
+
|
|
337
419
|
### `ufw`
|
|
338
420
|
|
|
339
421
|
| Method | Signature | Idempotent |
|
|
@@ -371,7 +453,7 @@ function myCustomModule(configPath: string, content: string): Module {
|
|
|
371
453
|
|
|
372
454
|
async apply(ssh: SshConnection | null, env: Environment): Promise<ModuleResult> {
|
|
373
455
|
if (!ssh) return failed(`[my-module: ${configPath}] SSH connection is required`)
|
|
374
|
-
await ssh.writeFile(configPath, content)
|
|
456
|
+
await ssh.writeFile(configPath, content, { mode: "0644" })
|
|
375
457
|
return {
|
|
376
458
|
meta: [meta.env("MY_MODULE_PATH", configPath)],
|
|
377
459
|
status: "changed",
|
|
@@ -399,23 +481,23 @@ function myCustomModule(configPath: string, content: string): Module {
|
|
|
399
481
|
|
|
400
482
|
Methods available on the `ssh` parameter:
|
|
401
483
|
|
|
402
|
-
| Method
|
|
403
|
-
|
|
|
404
|
-
| `ssh.exec(cmd, options?)`
|
|
405
|
-
| `ssh.test(cmd)`
|
|
406
|
-
| `ssh.output(cmd)`
|
|
407
|
-
| `ssh.lines(cmd)`
|
|
408
|
-
| `ssh.exists(path)`
|
|
409
|
-
| `ssh.readFile(path)`
|
|
410
|
-
| `ssh.writeFile(path, content)`
|
|
411
|
-
| `ssh.uploadFile(local, remote)`
|
|
412
|
-
| `ssh.downloadFile(remote, local)`
|
|
413
|
-
| `ssh.sha256(path)`
|
|
414
|
-
| `ssh.addPort(port)`
|
|
415
|
-
| `ssh.disconnect()`
|
|
416
|
-
| `ssh.getConnectionInfo()`
|
|
417
|
-
| `ssh.probeSudo()`
|
|
418
|
-
| `ssh.updateHost(host)`
|
|
484
|
+
| Method | Return type | Description |
|
|
485
|
+
| ------------------------------------------------ | --------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
486
|
+
| `ssh.exec(cmd, options?)` | `Promise<ExecResult>` | Run command, get `{ code, stdout, stderr }`. Throws on non-zero unless `ignoreExitCode: true`. |
|
|
487
|
+
| `ssh.test(cmd)` | `Promise<boolean>` | Run command, return `true` if exit code is 0. |
|
|
488
|
+
| `ssh.output(cmd)` | `Promise<string>` | Run command, return trimmed stdout. |
|
|
489
|
+
| `ssh.lines(cmd)` | `Promise<string[]>` | Run command, return stdout split into lines. |
|
|
490
|
+
| `ssh.exists(path)` | `Promise<boolean>` | Check if remote path exists. |
|
|
491
|
+
| `ssh.readFile(path)` | `Promise<string>` | Read remote file content. |
|
|
492
|
+
| `ssh.writeFile(path, content, { mode: "0644" })` | `Promise<void>` | Write content to remote file. `mode` is required; choose an explicit file mode. |
|
|
493
|
+
| `ssh.uploadFile(local, remote)` | `Promise<void>` | Upload local file via SFTP. |
|
|
494
|
+
| `ssh.downloadFile(remote, local)` | `Promise<void>` | Download remote file. |
|
|
495
|
+
| `ssh.sha256(path)` | `Promise<string \| null>` | Get SHA-256 hex digest, or null if not found. |
|
|
496
|
+
| `ssh.addPort(port)` | `void` | Register an additional port opened on the remote host (advanced). |
|
|
497
|
+
| `ssh.disconnect()` | `void` | Close the SSH connection. |
|
|
498
|
+
| `ssh.getConnectionInfo()` | `{ host, port, privateKeyPath?, user }` | Return current connection parameters. |
|
|
499
|
+
| `ssh.probeSudo()` | `Promise<void>` | Probe/cache sudo access; prompts interactively if needed. |
|
|
500
|
+
| `ssh.updateHost(host)` | `void` | Update the target host address (e.g., after IP change). |
|
|
419
501
|
|
|
420
502
|
### ExecOptions
|
|
421
503
|
|
|
@@ -423,12 +505,18 @@ Methods available on the `ssh` parameter:
|
|
|
423
505
|
{
|
|
424
506
|
env?: Record<string, string> // Extra environment variables
|
|
425
507
|
ignoreExitCode?: boolean // Don't throw on non-zero exit
|
|
508
|
+
input?: string // Payload written to stdin before EOF
|
|
509
|
+
maxOutputBytes?: number // Cap stored stdout/stderr bytes
|
|
426
510
|
secrets?: string[] // Strings to mask in error output
|
|
427
511
|
silent?: boolean // Suppress stdout/stderr
|
|
428
512
|
timeout?: number // Abort after N milliseconds
|
|
429
513
|
}
|
|
430
514
|
```
|
|
431
515
|
|
|
516
|
+
Prefer `input` for secret payloads instead of shell arguments, so values do not
|
|
517
|
+
leak through process lists, `/proc/<pid>/cmdline`, or SSH logs. Add the same
|
|
518
|
+
values to `secrets` only as an additional masking guard for error output.
|
|
519
|
+
|
|
432
520
|
## Template System
|
|
433
521
|
|
|
434
522
|
Files deployed via `file.template(remotePath, localTemplatePath)` can contain `{{KEY}}` placeholders.
|
|
@@ -513,7 +601,7 @@ Conditionally run modules based on package state on the remote host.
|
|
|
513
601
|
|
|
514
602
|
```typescript
|
|
515
603
|
when.packageInstalled("ufw", ufw.disabled())
|
|
516
|
-
when.packageAbsent("docker-ce",
|
|
604
|
+
when.packageAbsent("docker-ce", pkg.installed("docker-ce"))
|
|
517
605
|
```
|
|
518
606
|
|
|
519
607
|
### `when.commandExists(name, ...modules)` / `when.commandMissing(name, ...modules)`
|
|
@@ -522,7 +610,7 @@ Conditionally run modules based on whether a command exists on the remote host.
|
|
|
522
610
|
|
|
523
611
|
```typescript
|
|
524
612
|
when.commandExists("docker", service.running("docker"))
|
|
525
|
-
when.commandMissing("docker",
|
|
613
|
+
when.commandMissing("docker", pkg.installed("docker-ce"))
|
|
526
614
|
```
|
|
527
615
|
|
|
528
616
|
### Filesystem guard variants
|
|
@@ -621,6 +709,97 @@ async check(ssh) {
|
|
|
621
709
|
}
|
|
622
710
|
```
|
|
623
711
|
|
|
712
|
+
## Dry-Run Diff Output (`--diff`)
|
|
713
|
+
|
|
714
|
+
Paratix runs a playbook in dry-run mode via `paratix apply <file> --dry-run`, which
|
|
715
|
+
reports per-module `changed (dry-run)` or `ok` based on each module's `check()`.
|
|
716
|
+
|
|
717
|
+
`--diff` enables a second, opt-in layer: modules that mark themselves as diff
|
|
718
|
+
producers also render a unified-diff block under their status line so the user
|
|
719
|
+
sees _what_ would change, not only _that_ something would change.
|
|
720
|
+
|
|
721
|
+
```
|
|
722
|
+
$ paratix apply server.ts --dry-run --diff
|
|
723
|
+
↺ /etc/ssh/sshd_config changed (dry-run)
|
|
724
|
+
│ --- /etc/ssh/sshd_config
|
|
725
|
+
│ +++ /local/path/sshd_config
|
|
726
|
+
│ -Port 22
|
|
727
|
+
│ +Port 2222
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
Rules:
|
|
731
|
+
|
|
732
|
+
- `--diff` requires `--dry-run`. The CLI rejects `paratix apply <file> --diff`
|
|
733
|
+
without `--dry-run` before any SSH connection or playbook side effect.
|
|
734
|
+
- Without `--diff`, the dry-run runs are byte-identical to before: no extra
|
|
735
|
+
remote round-trips, no diff output.
|
|
736
|
+
- Modules that do not opt in keep showing only `changed (dry-run)`.
|
|
737
|
+
- Every diff line is masked through the registered-secret sink and a terminal
|
|
738
|
+
sanitizer before printing, so secret-laden file contents never leak verbatim.
|
|
739
|
+
- `_dryRunDetail` (inline suffix) and `diff` (multi-line block) are independent
|
|
740
|
+
output layers — a module may emit both at once; the runner renders detail next
|
|
741
|
+
to the status line and the diff below it.
|
|
742
|
+
|
|
743
|
+
Diff-producing built-in modules:
|
|
744
|
+
|
|
745
|
+
| Modul | Diff-Inhalt |
|
|
746
|
+
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
747
|
+
| `file.copy` | Unified diff zwischen Remote-Datei und lokaler Quelle. |
|
|
748
|
+
| `file.template` | Unified diff zwischen Remote-Datei und gerendertem Template. |
|
|
749
|
+
| `sysctl.set` | `key = old → new` für die Soll-Konfiguration. |
|
|
750
|
+
| `hostname.set` | `hostname = old → new`. |
|
|
751
|
+
| `swap.file` | Diff des `/etc/fstab`-Eintrags (oder Entfernung der Zeile bei `state: "absent"`). |
|
|
752
|
+
| `swap.swappiness` | `vm.swappiness = old → new` (via `sysctl.set`). |
|
|
753
|
+
| `swap.vfsCachePressure` | `vm.vfs_cache_pressure = old → new` (via `sysctl.set`). |
|
|
754
|
+
| `cron.job` / `cron.absent` | Unified diff der Crontab des Ziel-Users. |
|
|
755
|
+
| `timer.scheduled` (present) | Konkatenierte Diffs der `.service`- und `.timer`-Unit-Dateien. |
|
|
756
|
+
| `timer.scheduled` (absent) / `timer.absent` | Liste der zu entfernenden Unit-Dateien. |
|
|
757
|
+
| `net.hosts` | Unified diff von `/etc/hosts`. |
|
|
758
|
+
| `quadlet.container` | Unified diff der `.container`-Unit-Datei. Wenn die Datei bereits passt, das `daemon-reload`-Flag aber fehlt, erscheint zusätzlich `(dry-run, daemon-reload pending)` als Detail-Suffix. |
|
|
759
|
+
|
|
760
|
+
### Implementing a Diff for a Custom Module
|
|
761
|
+
|
|
762
|
+
A module participates in `--diff` output by setting `_dryRunDiffProducer: true`
|
|
763
|
+
and implementing `_applyDryRun`. The runner calls `_applyDryRun` only when the
|
|
764
|
+
user passed `--diff` and `check()` returned `"needs-apply"`.
|
|
765
|
+
|
|
766
|
+
```typescript
|
|
767
|
+
import { buildUnifiedDiff } from "paratix/modules" // not exported publicly today
|
|
768
|
+
// or, for scalar drift:
|
|
769
|
+
import { buildKeyValueDiff } from "paratix/modules"
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
```typescript
|
|
773
|
+
return {
|
|
774
|
+
_dryRunDiffProducer: true,
|
|
775
|
+
async _applyDryRun(ssh) {
|
|
776
|
+
if (!ssh) return { status: "changed" }
|
|
777
|
+
try {
|
|
778
|
+
const current = (await ssh.exists(remotePath)) ? await ssh.readFile(remotePath) : ""
|
|
779
|
+
const diff = buildUnifiedDiff(current, desired, {
|
|
780
|
+
currentLabel: remotePath,
|
|
781
|
+
desiredLabel: localPath,
|
|
782
|
+
})
|
|
783
|
+
return diff === "" ? { status: "changed" } : { diff, status: "changed" }
|
|
784
|
+
} catch {
|
|
785
|
+
// A read failure must not abort the dry-run. Drop the diff and fall back
|
|
786
|
+
// to the generic "(dry-run)" suffix.
|
|
787
|
+
return { status: "changed" }
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
async check(ssh) {
|
|
791
|
+
/* unchanged */
|
|
792
|
+
},
|
|
793
|
+
async apply(ssh) {
|
|
794
|
+
/* unchanged */
|
|
795
|
+
},
|
|
796
|
+
name: "myModule: ...",
|
|
797
|
+
}
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
The diff string is plain text — no ANSI codes. The output layer applies colors
|
|
801
|
+
(`-` red, `+` green, headers dimmed) and indentation.
|
|
802
|
+
|
|
624
803
|
## Do's and Don'ts
|
|
625
804
|
|
|
626
805
|
### DO
|
|
@@ -658,6 +837,7 @@ async check(ssh) {
|
|
|
658
837
|
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.
|
|
659
838
|
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.
|
|
660
839
|
15. Do NOT return loose `meta: { ... }` maps from custom modules -- always use typed meta entries.
|
|
840
|
+
16. Do NOT set `ssh.sudoPassword` to a value from `op.resolve()` or `meta.env()` -- `SshConfig` is frozen at server construction time, before any module in `run` executes. Use a static source like `process.env.SUDO_PASSWORD` or a variable populated before the server definition is imported.
|
|
661
841
|
|
|
662
842
|
## Testing Patterns
|
|
663
843
|
|
|
@@ -703,7 +883,8 @@ describe("myModule", () => {
|
|
|
703
883
|
### `createMockSsh` behavior
|
|
704
884
|
|
|
705
885
|
- Accepts `Record<string, Partial<ExecResult>>` mapping command strings to responses.
|
|
706
|
-
-
|
|
886
|
+
- Unknown commands fail closed with an error. Add every expected command to the
|
|
887
|
+
response map, or the helper throws instead of returning a successful default.
|
|
707
888
|
- `ssh.test(cmd)` returns `code === 0`.
|
|
708
889
|
- `ssh.exists(path)` delegates to `ssh.test("[ -e '<path>' ]")`.
|
|
709
890
|
- `ssh.readFile(path)` delegates to `ssh.output("cat '<path>'")`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paratix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"description": "Idempotent VPS setup tool in TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
"paratix": "./dist/cli.js"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"commander": "^
|
|
18
|
+
"commander": "^15.0.0",
|
|
19
19
|
"picocolors": "^1.1.1",
|
|
20
20
|
"ssh2": "^1.16.0",
|
|
21
|
-
"tsx": "^4.
|
|
21
|
+
"tsx": "^4.22.3"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@types/node": "
|
|
24
|
+
"@types/node": "24.12.4",
|
|
25
25
|
"@types/ssh2": "^1.15.4",
|
|
26
26
|
"tsup": "^8.4.0",
|
|
27
|
-
"typescript": "^
|
|
28
|
-
"vitest": "^
|
|
27
|
+
"typescript": "^6.0.3",
|
|
28
|
+
"vitest": "^4.1.7"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=24.0.0"
|
|
@@ -55,8 +55,10 @@
|
|
|
55
55
|
"license": "MIT",
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsup",
|
|
58
|
-
"test": "
|
|
59
|
-
"test:
|
|
58
|
+
"test": "pnpm test:unit && pnpm test:dist",
|
|
59
|
+
"test:dist": "pnpm build && vitest run --config vitest.distribution.config.ts",
|
|
60
|
+
"test:integration": "pnpm build && vitest run --config vitest.integration.config.ts",
|
|
61
|
+
"test:unit": "vitest run",
|
|
60
62
|
"test:watch": "vitest"
|
|
61
63
|
}
|
|
62
64
|
}
|