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/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 | Idempotent |
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 | Signature | Idempotent |
155
- | ---------- | ----------------------------------------------------------------------------------------------- | ---------- |
156
- | `cron.job` | `(user: string, name: string, options: { job: string; state?: "absent" \| "present" }): Module` | Yes |
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 | Idempotent |
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` | Yes |
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 | Idempotent |
267
- | ------------------------ | ------------------------------------------------------------------------------- | ---------- |
268
- | `releaseUpgrade.upgrade` | `(options?: { dryRun?: boolean; resolveHost?: () => Promise<string> }): Module` | Yes |
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 | Idempotent |
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 | Return type | Description |
403
- | --------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------- |
404
- | `ssh.exec(cmd, options?)` | `Promise<ExecResult>` | Run command, get `{ code, stdout, stderr }`. Throws on non-zero unless `ignoreExitCode: true`. |
405
- | `ssh.test(cmd)` | `Promise<boolean>` | Run command, return `true` if exit code is 0. |
406
- | `ssh.output(cmd)` | `Promise<string>` | Run command, return trimmed stdout. |
407
- | `ssh.lines(cmd)` | `Promise<string[]>` | Run command, return stdout split into lines. |
408
- | `ssh.exists(path)` | `Promise<boolean>` | Check if remote path exists. |
409
- | `ssh.readFile(path)` | `Promise<string>` | Read remote file content. |
410
- | `ssh.writeFile(path, content)` | `Promise<void>` | Write content to remote file. |
411
- | `ssh.uploadFile(local, remote)` | `Promise<void>` | Upload local file via SFTP. |
412
- | `ssh.downloadFile(remote, local)` | `Promise<void>` | Download remote file. |
413
- | `ssh.sha256(path)` | `Promise<string \| null>` | Get SHA-256 hex digest, or null if not found. |
414
- | `ssh.addPort(port)` | `void` | Register an additional port opened on the remote host (advanced). |
415
- | `ssh.disconnect()` | `void` | Close the SSH connection. |
416
- | `ssh.getConnectionInfo()` | `{ host, port, privateKeyPath?, user }` | Return current connection parameters. |
417
- | `ssh.probeSudo()` | `Promise<void>` | Probe/cache sudo access; prompts interactively if needed. |
418
- | `ssh.updateHost(host)` | `void` | Update the target host address (e.g., after IP change). |
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", package.installed("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", package.installed("docker-ce"))
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
- - Default response: `{ code: 0, stdout: "", stderr: "" }`.
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.9.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": "^13.1.0",
18
+ "commander": "^15.0.0",
19
19
  "picocolors": "^1.1.1",
20
20
  "ssh2": "^1.16.0",
21
- "tsx": "^4.19.0"
21
+ "tsx": "^4.22.3"
22
22
  },
23
23
  "devDependencies": {
24
- "@types/node": "^25.5.0",
24
+ "@types/node": "24.12.4",
25
25
  "@types/ssh2": "^1.15.4",
26
26
  "tsup": "^8.4.0",
27
- "typescript": "^5.8.0",
28
- "vitest": "^3.1.0"
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": "vitest run",
59
- "test:integration": "vitest run --config vitest.integration.config.ts",
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
  }