paratix 0.12.8 → 0.14.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.
@@ -157,6 +157,17 @@ type Module = {
157
157
  * @returns `"ok"` if the desired state is already present, `"needs-apply"` otherwise.
158
158
  */
159
159
  check: (ssh: null | SshConnection, environment: Environment) => Promise<"needs-apply" | "ok">;
160
+ /**
161
+ * Internal discriminator that distinguishes leaf modules from recipes.
162
+ *
163
+ * Leaf modules leave this `undefined` (treated as `"module"`); recipes set
164
+ * `"recipe"` so the runner can narrow to the recipe contract via the
165
+ * `isRecipe` type guard instead of an unsafe structural cast. Intentionally
166
+ * optional so existing custom modules and playbooks stay source-compatible —
167
+ * they never need to set it.
168
+ * @internal
169
+ */
170
+ kind?: "module" | "recipe";
160
171
  /**
161
172
  * When true the module runs locally instead of over SSH.
162
173
  * The `ssh` parameter will be `null` in check/apply.
@@ -240,8 +251,16 @@ type SshConnection = {
240
251
  probeSudo: () => Promise<void>;
241
252
  /** Read the full contents of a remote file as a string. */
242
253
  readFile: (remotePath: string) => Promise<string>;
243
- /** Reconnect the SSH session using the current host and registered port candidates. */
244
- reconnect: () => Promise<void>;
254
+ /**
255
+ * Reconnect the SSH session using the current host and registered port
256
+ * candidates. An explicit `reconnectTimeout` in the SSH config always takes
257
+ * precedence; when it is unset, `options.defaultTimeout` (in milliseconds)
258
+ * overrides the generic reconnect default for this call — used by the reboot
259
+ * path to grant a longer window.
260
+ */
261
+ reconnect: (options?: {
262
+ defaultTimeout?: number;
263
+ }) => Promise<void>;
245
264
  /** Remove a previously registered port from the reconnect candidate list. */
246
265
  removePort: (port: number) => void;
247
266
  /** Return the SHA-256 hex digest of a remote file, or `null` if not found. */
@@ -250,13 +269,13 @@ type SshConnection = {
250
269
  test: (command: string) => Promise<boolean>;
251
270
  /** Update the target host address (e.g. after a reboot with new IP). */
252
271
  updateHost: (host: string) => void;
253
- /** Upload a local file to the remote host via SFTP. */
272
+ /** Upload a local file to the remote host via SFTP. `options.mode` is optional and defaults to `"0600"`. */
254
273
  uploadFile: (localPath: string, remotePath: string, options?: {
255
274
  mode?: string;
256
275
  }) => Promise<void>;
257
- /** Write a string to a remote file, creating or overwriting it. */
258
- writeFile: (remotePath: string, content: string, options: {
259
- mode: string;
276
+ /** Write a string to a remote file, creating or overwriting it. `options.mode` is optional and defaults to `"0600"`. */
277
+ writeFile: (remotePath: string, content: string, options?: {
278
+ mode?: string;
260
279
  }) => Promise<void>;
261
280
  };
262
281
  /** SSH connection parameters for a server. */
@@ -564,6 +583,7 @@ declare const command: {
564
583
  * If it exits `0`, the command is considered already done.
565
584
  * @param options.name - An optional display name shown in the run output.
566
585
  * @param options.secrets - Secret values that must be redacted from command output.
586
+ * @param options.timeout - Maximum runtime for the command in milliseconds.
567
587
  * @returns A Module that executes the shell command.
568
588
  *
569
589
  * @example
@@ -576,6 +596,7 @@ declare const command: {
576
596
  check?: string;
577
597
  name?: string;
578
598
  secrets?: string[];
599
+ timeout?: number;
579
600
  }): Module;
580
601
  };
581
602
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { E as Environment, M as Module, a as ModuleMetaEntry, b as MetaEnvironmentValue, c as EnvironmentMetaEntry, S as SshdPortMetaEntry, d as SystemHostMetaEntry, e as SystemRebootMetaEntry, f as ModuleResult, g as ExecResult, h as ModuleStatus, i as SshConnection, O as OrchestrationStep, j as ShutdownSignal, k as ServerDefinition } from './index-udpAybq3.js';
2
- export { l as EnvironmentValue, m as ExecOptions, N as NEEDS_APPLY, n as SshConfig, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from './index-udpAybq3.js';
1
+ import { E as Environment, M as Module, a as ModuleMetaEntry, b as MetaEnvironmentValue, c as EnvironmentMetaEntry, S as SshdPortMetaEntry, d as SystemHostMetaEntry, e as SystemRebootMetaEntry, f as ModuleResult, g as ExecResult, h as ModuleStatus, i as SshConnection, O as OrchestrationStep, j as ShutdownSignal, k as ServerDefinition } from './index-DlZISXJx.js';
2
+ export { l as EnvironmentValue, m as ExecOptions, N as NEEDS_APPLY, n as SshConfig, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from './index-DlZISXJx.js';
3
3
 
4
4
  /**
5
5
  * Fail the run if a condition on the current env is not satisfied.
@@ -185,11 +185,16 @@ type SignalHooks = {
185
185
 
186
186
  /**
187
187
  * Internal representation of a recipe module.
188
- * The `_isRecipe` flag lets the runner distinguish recipes from leaf modules.
188
+ *
189
+ * The `kind: "recipe"` discriminator lets the runner distinguish recipes from
190
+ * leaf modules through the {@link isRecipe} type guard, forming a discriminated
191
+ * union with plain {@link Module} values (which leave `kind` unset). The child
192
+ * `_modules` and optional `_signals` are recipe-specific data that TypeScript
193
+ * narrows to once `isRecipe` confirmed the discriminator — no structural cast
194
+ * is required anywhere.
189
195
  * @internal
190
196
  */
191
197
  type RecipeModule = {
192
- _isRecipe: true;
193
198
  _modules: Module[];
194
199
  _signals?: Module[];
195
200
  apply: (ssh: null | SshConnection, environment: Environment, options?: {
@@ -199,6 +204,7 @@ type RecipeModule = {
199
204
  signalHooks?: SignalHooks;
200
205
  verbose?: boolean;
201
206
  }) => Promise<ModuleResult>;
207
+ kind: "recipe";
202
208
  } & Module;
203
209
  /**
204
210
  * Group a list of modules into a named, self-contained recipe.
@@ -215,8 +221,11 @@ type RecipeModule = {
215
221
  * @returns A RecipeModule that groups the child modules.
216
222
  *
217
223
  * @example
224
+ * import { recipe } from "paratix"
225
+ * import { package as pkg, file, service } from "paratix/modules"
226
+ *
218
227
  * export const nginxRecipe = recipe("nginx", [
219
- * apt.installed("nginx"),
228
+ * pkg.installed("nginx"),
220
229
  * file.template("/etc/nginx/nginx.conf", "./files/nginx.conf.tmpl"),
221
230
  * service.enabled("nginx"),
222
231
  * ], {
@@ -239,11 +248,14 @@ declare function recipe(name: string, modules: Module[], options?: {
239
248
  * @throws {Error} When any required field is missing or empty.
240
249
  *
241
250
  * @example
251
+ * import { server } from "paratix"
252
+ * import { package as pkg } from "paratix/modules"
253
+ *
242
254
  * export default server({
243
255
  * name: "web-01",
244
256
  * host: "10.0.0.1",
245
257
  * ssh: { user: "root", ports: [22], privateKey: "~/.ssh/id_ed25519" }, // "~" is expanded
246
- * run: [apt.installed("nginx")],
258
+ * run: [pkg.installed("nginx")],
247
259
  * });
248
260
  */
249
261
  declare function server(config: ServerDefinition): ServerDefinition;
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ import {
61
61
  user,
62
62
  validateHostLabel,
63
63
  validateSshConfig
64
- } from "./chunk-M3ZQJNKM.js";
64
+ } from "./chunk-475OT32R.js";
65
65
 
66
66
  // src/conditionalModules.ts
67
67
  function createConditionalApplyState(environment) {
@@ -536,6 +536,10 @@ var DEFAULT_TERMINAL_COLUMNS = 100;
536
536
  var MIN_PACKAGE_COLUMNS_WIDTH = 24;
537
537
  var MIN_PACKAGE_COLUMN_WIDTH = 18;
538
538
  var MIN_ANIMATED_LINE_COLUMNS = 8;
539
+ var ELAPSED_DISPLAY_THRESHOLD_MS = 1e3;
540
+ var MILLISECONDS_PER_SECOND = 1e3;
541
+ var MILLISECONDS_PER_MINUTE = 6e4;
542
+ var SECONDS_PER_MINUTE = 60;
539
543
  function splitPackageModuleName(name) {
540
544
  for (const prefix of ["package.installed: ", "package.absent: "]) {
541
545
  if (!name.startsWith(prefix)) continue;
@@ -573,6 +577,16 @@ function getPackageSummaryDetail(packageCount, detail) {
573
577
  const packageLabel = packageCount === 1 ? "1 package" : `${packageCount} packages`;
574
578
  return detail == null ? packageLabel : `${packageLabel} \xB7 ${detail}`;
575
579
  }
580
+ function formatModuleElapsed(elapsedMs) {
581
+ if (!Number.isFinite(elapsedMs) || elapsedMs < ELAPSED_DISPLAY_THRESHOLD_MS) return void 0;
582
+ if (elapsedMs < MILLISECONDS_PER_MINUTE) {
583
+ return `${(elapsedMs / MILLISECONDS_PER_SECOND).toFixed(1)}s`;
584
+ }
585
+ const totalSeconds = Math.floor(elapsedMs / MILLISECONDS_PER_SECOND);
586
+ const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
587
+ const seconds = totalSeconds % SECONDS_PER_MINUTE;
588
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
589
+ }
576
590
  function fitAnimatedModuleLine(line, columns) {
577
591
  if (columns === void 0 || columns < MIN_ANIMATED_LINE_COLUMNS) return line;
578
592
  const plainLine = stripVTControlCharacters(line);
@@ -624,6 +638,7 @@ function getSharedLiveOutputState() {
624
638
  const existing = registry[LIVE_OUTPUT_STATE_KEY];
625
639
  if (existing != null) return existing;
626
640
  const created = {
641
+ activeModuleStartedAt: null,
627
642
  activeRecipeGuideDepths: [],
628
643
  activeSpinner: null,
629
644
  cursorHidden: false,
@@ -666,7 +681,7 @@ function getGuideDot(depth) {
666
681
  return depth % 2 === 0 ? pc.gray("\xB7") : pc.cyan("\xB7");
667
682
  }
668
683
  function buildGuideIndent(baseIndent, options) {
669
- const indentCharacters = Array.from(baseIndent);
684
+ const indentCharacters = [...baseIndent];
670
685
  const guideDepths = [
671
686
  ...options?.activeGuideDepths ?? liveOutputState.activeRecipeGuideDepths,
672
687
  ...options?.extraGuideDepths ?? []
@@ -702,20 +717,27 @@ async function withRecipeOutputScope(scopedOperation) {
702
717
  try {
703
718
  return await scopedOperation();
704
719
  } finally {
705
- liveOutputState.activeRecipeGuideDepths = liveOutputState.activeRecipeGuideDepths.filter((depth) => depth !== liveOutputState.recipeOutputDepth);
720
+ liveOutputState.activeRecipeGuideDepths = liveOutputState.activeRecipeGuideDepths.filter(
721
+ (depth) => depth !== liveOutputState.recipeOutputDepth
722
+ );
706
723
  liveOutputState.pendingRecipeClosureGuideDepths = [liveOutputState.recipeOutputDepth];
707
724
  liveOutputState.recipeOutputDepth -= 1;
708
725
  }
709
726
  }
727
+ function getActiveModuleElapsed() {
728
+ if (liveOutputState.activeModuleStartedAt == null) return void 0;
729
+ return formatModuleElapsed(Date.now() - liveOutputState.activeModuleStartedAt);
730
+ }
710
731
  function renderModuleLine(parameters) {
711
- const { detail, extraGuideDepths = [], name, status, waitingFrame } = parameters;
732
+ const { detail, elapsed, extraGuideDepths = [], name, status, waitingFrame } = parameters;
712
733
  const baseIndent = getModuleIndent();
713
734
  const indent = buildGuideIndent(baseIndent, { extraGuideDepths });
714
735
  const icon = getModuleIcon(status, waitingFrame);
715
736
  const statusText = getModuleStatusText(status);
716
737
  const detailSuffix = detail == null ? "" : ` ${pc.dim(detail)}`;
738
+ const elapsedSuffix = elapsed == null ? "" : ` ${pc.dim(elapsed)}`;
717
739
  const alignedNameWidth = Math.max(MODULE_NAME_WIDTH - baseIndent.length, MIN_MODULE_NAME_WIDTH);
718
- return `${indent}${icon} ${name.padEnd(alignedNameWidth)} ${statusText}${detailSuffix}`;
740
+ return `${indent}${icon} ${name.padEnd(alignedNameWidth)} ${statusText}${detailSuffix}${elapsedSuffix}`;
719
741
  }
720
742
  function hideCursor() {
721
743
  if (liveOutputState.cursorHidden) return;
@@ -745,6 +767,7 @@ function stopAnimatedModuleLine(clearCurrentLine = false) {
745
767
  }
746
768
  }
747
769
  function startModuleSpinner(name, detail) {
770
+ liveOutputState.activeModuleStartedAt = Date.now();
748
771
  if (!supportsAnimatedModuleOutput()) return;
749
772
  clearPendingRecipeClosureGuides();
750
773
  stopAnimatedModuleLine();
@@ -765,6 +788,7 @@ function startModuleSpinner(name, detail) {
765
788
  writeAnimatedModuleLine(
766
789
  renderModuleLine({
767
790
  detail: spinner.detail,
791
+ elapsed: getActiveModuleElapsed(),
768
792
  name: displayModule.name,
769
793
  status: "waiting",
770
794
  waitingFrame: SPINNER_FRAMES[spinner.frameIndex]
@@ -777,6 +801,7 @@ function startModuleSpinner(name, detail) {
777
801
  writeAnimatedModuleLine(
778
802
  renderModuleLine({
779
803
  detail: displayModule.detail,
804
+ elapsed: getActiveModuleElapsed(),
780
805
  name: displayModule.name,
781
806
  status: "waiting",
782
807
  waitingFrame: SPINNER_FRAMES[0]
@@ -789,7 +814,10 @@ function printRecipeHeader(name) {
789
814
  const header = pc.bold(pc.blue(`[${name}]`));
790
815
  console.log(`${buildGuideIndent(getRecipeHeaderIndent())}${header}`);
791
816
  if (liveOutputState.recipeOutputDepth >= 0) {
792
- liveOutputState.activeRecipeGuideDepths = [...liveOutputState.activeRecipeGuideDepths, liveOutputState.recipeOutputDepth];
817
+ liveOutputState.activeRecipeGuideDepths = [
818
+ ...liveOutputState.activeRecipeGuideDepths,
819
+ liveOutputState.recipeOutputDepth
820
+ ];
793
821
  }
794
822
  }
795
823
  function colorizeDiffLine(line) {
@@ -836,8 +864,11 @@ function printRenderedModuleResult(parameters) {
836
864
  status: parameters.status,
837
865
  terminalColumns: process.stdout.columns
838
866
  });
867
+ const elapsed = getActiveModuleElapsed();
868
+ liveOutputState.activeModuleStartedAt = null;
839
869
  const line = renderModuleLine({
840
870
  detail: displayModule.detail,
871
+ elapsed,
841
872
  extraGuideDepths,
842
873
  name: displayModule.name,
843
874
  status: parameters.status
@@ -964,7 +995,9 @@ function printCauseChain(error) {
964
995
  if (visited.has(cause)) return;
965
996
  visited.add(cause);
966
997
  }
967
- console.error(pc.red(`${getErrorIndent()}Cause: ${maskRegisteredSecrets(formatCauseValue(cause))}`));
998
+ console.error(
999
+ pc.red(`${getErrorIndent()}Cause: ${maskRegisteredSecrets(formatCauseValue(cause))}`)
1000
+ );
968
1001
  if (cause instanceof CommandError) {
969
1002
  printVerboseCommandError(
970
1003
  maskRegisteredSecrets(cause.fullStdout),
@@ -1191,11 +1224,11 @@ async function runSignalModules(parameters) {
1191
1224
 
1192
1225
  // src/recipe.ts
1193
1226
  var INTERRUPTED_BEFORE_APPLY = /* @__PURE__ */ Symbol("recipe-interrupted-before-apply");
1194
- function isRecipeModuleLike(module) {
1195
- return module._isRecipe === true;
1227
+ function isRecipe(module) {
1228
+ return module.kind === "recipe";
1196
1229
  }
1197
1230
  function printRecipeChildResult(module, result) {
1198
- if (isRecipeModuleLike(module)) {
1231
+ if (isRecipe(module)) {
1199
1232
  printRecipeModuleResult(module.name, result.status, result.detail);
1200
1233
  return;
1201
1234
  }
@@ -1262,7 +1295,7 @@ async function checkRecipeChild(targetModule, connection, currentEnvironment) {
1262
1295
  return targetModule.check(connection, currentEnvironment);
1263
1296
  }
1264
1297
  async function applyRecipeChild(parameters) {
1265
- if (isRecipeModuleLike(parameters.targetModule)) {
1298
+ if (isRecipe(parameters.targetModule)) {
1266
1299
  return parameters.targetModule.apply(parameters.connection, parameters.currentEnvironment, {
1267
1300
  onChildStep: parameters.onChildStep,
1268
1301
  onSignalStep: parameters.onSignalStep,
@@ -1469,7 +1502,6 @@ function createRecipeDryRunApply(name, modules, needsDryRunApply) {
1469
1502
  const result = await dryRunRecipeModule({
1470
1503
  environment,
1471
1504
  recipeModule: {
1472
- _isRecipe: true,
1473
1505
  _modules: modules,
1474
1506
  async apply() {
1475
1507
  await Promise.resolve();
@@ -1479,6 +1511,7 @@ function createRecipeDryRunApply(name, modules, needsDryRunApply) {
1479
1511
  await Promise.resolve();
1480
1512
  return "ok";
1481
1513
  },
1514
+ kind: "recipe",
1482
1515
  name
1483
1516
  },
1484
1517
  shutdownSignal: parameters?.shutdownSignal,
@@ -1498,7 +1531,6 @@ function recipe(name, modules, options) {
1498
1531
  ...modules.some((module) => module._dryRunBlocker === true) ? { _dryRunBlocker: true } : {},
1499
1532
  ...modules.some((module) => module._dryRunMetaProducer === true) ? { _dryRunMetaProducer: true } : {},
1500
1533
  ...applyDryRun == null ? {} : { _applyDryRun: applyDryRun },
1501
- _isRecipe: true,
1502
1534
  _modules: modules,
1503
1535
  _signals: options?.signals,
1504
1536
  _supportsChildStepHook: true,
@@ -1526,6 +1558,7 @@ function recipe(name, modules, options) {
1526
1558
  }
1527
1559
  return "ok";
1528
1560
  },
1561
+ kind: "recipe",
1529
1562
  name
1530
1563
  };
1531
1564
  }
@@ -1634,4 +1667,3 @@ export {
1634
1667
  user,
1635
1668
  when
1636
1669
  };
1637
- //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- export { V as UpgradeOptions, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from '../index-udpAybq3.js';
1
+ export { V as UpgradeOptions, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from '../index-DlZISXJx.js';
@@ -27,7 +27,7 @@ import {
27
27
  timer,
28
28
  ufw,
29
29
  user
30
- } from "../chunk-M3ZQJNKM.js";
30
+ } from "../chunk-475OT32R.js";
31
31
  export {
32
32
  apt,
33
33
  archive,
@@ -58,4 +58,3 @@ export {
58
58
  ufw,
59
59
  user
60
60
  };
61
- //# sourceMappingURL=index.js.map
package/llm-guide.md CHANGED
@@ -1,11 +1,18 @@
1
- # Paratix -- LLM Code Guide
1
+ # Paratix TypeScript API and Module Reference
2
2
 
3
- > This file helps LLMs write correct Paratix code. Read this before generating playbooks or modules.
3
+ This is the complete reference for writing Paratix playbooks and custom modules. It covers the
4
+ TypeScript API, built-in modules, authoring patterns, and common mistakes.
4
5
 
5
6
  ## Overview
6
7
 
7
8
  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
 
10
+ ## Agent authoring guidance
11
+
12
+ When generating or changing Paratix playbooks or custom modules, automated coding agents should use
13
+ this document as the complete API reference. Read the relevant API and module sections before making
14
+ changes, and follow the [Do's and Don'ts](#dos-and-donts) and [testing patterns](#testing-patterns).
15
+
9
16
  ## Imports
10
17
 
11
18
  Paratix has exactly two import paths:
@@ -150,9 +157,12 @@ export default server({
150
157
 
151
158
  ### `command`
152
159
 
153
- | Method | Signature | Idempotent |
154
- | --------------- | ---------------------------------------------------------------------------------------- | ----------------- |
155
- | `command.shell` | `(cmd: string, options?: { check?: string; name?: string; secrets?: string[] }): Module` | Only with `check` |
160
+ | Method | Signature | Idempotent |
161
+ | --------------- | ---------------------------------------------------------------------------------------------------------- | ----------------- |
162
+ | `command.shell` | `(cmd: string, options?: { check?: string; name?: string; secrets?: string[]; timeout?: number }): Module` | Only with `check` |
163
+
164
+ `command.shell` accepts `timeout` in milliseconds to limit the runtime of the applied command. The
165
+ idempotency guard configured through `check` is not affected by this timeout.
156
166
 
157
167
  ### `cron`
158
168
 
@@ -212,9 +222,9 @@ very slow artifact hosts or large downloads.
212
222
  | `file.stat` | `(remotePath: string): Module` | No (always-applies) |
213
223
  | `file.template` | `(remotePath: string, templatePath: string, options?: { mode?: string; owner?: string; strict?: boolean }): Module` | Yes |
214
224
 
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" }`.
225
+ **Note on `file.copy` and the default mode:** When `options.mode` is omitted, `file.copy` sets the mode to the documented default of `0644`. The mode is applied during upload (`uploadFile` with `{ mode }`) and compared with the desired value in `check`, so subsequent runs detect mode drift (for example, a manual `chmod 0600`) as `needs-apply`. Pass `{ mode: "0600" }` explicitly when more restrictive permissions are required, such as for secrets.
216
226
 
217
- **Hinweis zu `file.line`:** Der `line`-Wert muss eine einzelne Zeile ohne CR/LF sein. Für mehrzeilige Inhalte `file.block` verwenden.
227
+ **Note on `file.line`:** The `line` value must be a single line without CR/LF characters. Use `file.block` for multiline content.
218
228
 
219
229
  ### `git`
220
230
 
@@ -342,7 +352,7 @@ rsync SSH process and does not depend on a local `known_hosts` entry.
342
352
  | `ssh.authorizedKeys` | `(user: string, key: string, options?: { state?: "absent" \| "present" }): Module` | Yes |
343
353
  | `ssh.knownHosts` | `(host: string, options?: { expectedFingerprint?: string; port?: number; publicKey?: string; state?: "absent" \| "present" }): Module` | Yes |
344
354
 
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.
355
+ **Trust anchor requirement for `ssh.knownHosts` (`state: "present"`):** In the default `present` state, either `expectedFingerprint` or `publicKey` must be set. Without a trust anchor, the module constructor throws immediately because `check` would otherwise treat any existing `known_hosts` entry, including older and potentially compromised TOFU acceptances, as `ok` and skip the anchor comparison. No anchor is required for `state: "absent"`, because that state removes the entry.
346
356
 
347
357
  ### `sshd`
348
358
 
@@ -481,23 +491,23 @@ function myCustomModule(configPath: string, content: string): Module {
481
491
 
482
492
  Methods available on the `ssh` parameter:
483
493
 
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). |
494
+ | Method | Return type | Description |
495
+ | ------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------- |
496
+ | `ssh.exec(cmd, options?)` | `Promise<ExecResult>` | Run command, get `{ code, stdout, stderr }`. Throws on non-zero unless `ignoreExitCode: true`. |
497
+ | `ssh.test(cmd)` | `Promise<boolean>` | Run command, return `true` if exit code is 0. |
498
+ | `ssh.output(cmd)` | `Promise<string>` | Run command, return trimmed stdout. |
499
+ | `ssh.lines(cmd)` | `Promise<string[]>` | Run command, return stdout split into lines. |
500
+ | `ssh.exists(path)` | `Promise<boolean>` | Check if remote path exists. |
501
+ | `ssh.readFile(path)` | `Promise<string>` | Read remote file content. |
502
+ | `ssh.writeFile(path, content, { mode: "0644" })` | `Promise<void>` | Write content to remote file. `mode` is optional and defaults to `"0600"`. |
503
+ | `ssh.uploadFile(local, remote, { mode: "0644" })` | `Promise<void>` | Upload local file via SFTP. `mode` is optional and defaults to `"0600"`. |
504
+ | `ssh.downloadFile(remote, local)` | `Promise<void>` | Download remote file. |
505
+ | `ssh.sha256(path)` | `Promise<string \| null>` | Get SHA-256 hex digest, or null if not found. |
506
+ | `ssh.addPort(port)` | `void` | Register an additional port opened on the remote host (advanced). |
507
+ | `ssh.disconnect()` | `void` | Close the SSH connection. |
508
+ | `ssh.getConnectionInfo()` | `{ host, port, privateKeyPath?, user }` | Return current connection parameters. |
509
+ | `ssh.probeSudo()` | `Promise<void>` | Probe/cache sudo access; prompts interactively if needed. |
510
+ | `ssh.updateHost(host)` | `void` | Update the target host address (e.g., after IP change). |
501
511
 
502
512
  ### ExecOptions
503
513
 
@@ -666,7 +676,7 @@ fail("This branch should be unreachable")
666
676
 
667
677
  ### `firstRun.stop(message?)`
668
678
 
669
- Stoppt den aktuellen Lauf kontrolliert, wenn Paratix mit `--first-run` gestartet wurde. Nützlich als explizite Staging-Grenze in Bootstrap-Playbooks.
679
+ Stops the current run cleanly when Paratix was started with `--first-run`. Useful as an explicit staging boundary in bootstrap playbooks.
670
680
 
671
681
  ```typescript
672
682
  firstRun.stop("Bootstrap foundation complete; rerun without --first-run to continue.")
@@ -674,7 +684,7 @@ firstRun.stop("Bootstrap foundation complete; rerun without --first-run to conti
674
684
 
675
685
  ### `signals.flush(message?)`
676
686
 
677
- Führt alle aktuell offenen Signale des aktiven Scopes sofort aus und setzt deren Pending-Zustand zurück.
687
+ Immediately runs all currently pending signals in the active scope and clears their pending state.
678
688
 
679
689
  ```typescript
680
690
  signals.flush("Reload services before the next bootstrap stage")
@@ -742,20 +752,20 @@ Rules:
742
752
 
743
753
  Diff-producing built-in modules:
744
754
 
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. |
755
+ | Module | Diff content |
756
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
757
+ | `file.copy` | Unified diff between the remote file and the local source. |
758
+ | `file.template` | Unified diff between the remote file and the rendered template. |
759
+ | `sysctl.set` | `key = old → new` for the desired configuration. |
760
+ | `hostname.set` | `hostname = old → new`. |
761
+ | `swap.file` | Diff of the `/etc/fstab` entry (or removal of the line for `state: "absent"`). |
762
+ | `swap.swappiness` | `vm.swappiness = old → new` (via `sysctl.set`). |
763
+ | `swap.vfsCachePressure` | `vm.vfs_cache_pressure = old → new` (via `sysctl.set`). |
764
+ | `cron.job` / `cron.absent` | Unified diff of the target user's crontab. |
765
+ | `timer.scheduled` (present) | Concatenated diffs of the `.service` and `.timer` unit files. |
766
+ | `timer.scheduled` (absent) / `timer.absent` | List of unit files to remove. |
767
+ | `net.hosts` | Unified diff of `/etc/hosts`. |
768
+ | `quadlet.container` | Unified diff of the `.container` unit file. If the file already matches but the `daemon-reload` flag is missing, `(dry-run, daemon-reload pending)` also appears as a detail suffix. |
759
769
 
760
770
  ### Implementing a Diff for a Custom Module
761
771
 
@@ -810,7 +820,7 @@ The diff string is plain text — no ANSI codes. The output layer applies colors
810
820
  4. For idempotency with `command.shell()`, always provide a `check` command.
811
821
  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()`.
812
822
  6. Use `service.restart()` and `service.reload()` as `signals` in recipes, not directly in `run`.
813
- 7. Use `signals.flush()` only als expliziten Checkpoint, wenn gestufte Flows einen vorgezogenen Signal-Flush brauchen.
823
+ 7. Use `signals.flush()` only as an explicit checkpoint when staged flows require an early signal flush.
814
824
  8. Always pass a date string to `package.upgrade()` and `package.update()` -- it is the idempotency key.
815
825
  9. Specify `ssh.ports` as an array -- the runner tries each port in order.
816
826
  10. Custom modules must implement both `check` and `apply`, both async.
@@ -831,9 +841,9 @@ The diff string is plain text — no ANSI codes. The output layer applies colors
831
841
  9. Do NOT use template syntax `{{key}}` in TypeScript code -- templates are only for files rendered via `file.template()`.
832
842
  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.
833
843
  11. Do NOT treat `signals.flush()` as a global queue flush -- it only affects the current scope.
834
- 12. `signals.flush()` flusht immer nur den aktuellen Scope:
835
- - in einer Recipe deren Recipe-Signale
836
- - auf Top-Level `server(...).signals`
844
+ 12. `signals.flush()` always flushes only the current scope:
845
+ - inside a recipe, that recipe's signals
846
+ - at the top level, `server(...).signals`
837
847
  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.
838
848
  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.
839
849
  15. Do NOT return loose `meta: { ... }` maps from custom modules -- always use typed meta entries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paratix",
3
- "version": "0.12.8",
3
+ "version": "0.14.0",
4
4
  "description": "Idempotent VPS setup tool in TypeScript",
5
5
  "type": "module",
6
6
  "files": [
@@ -23,6 +23,7 @@
23
23
  "devDependencies": {
24
24
  "@types/node": "24.13.2",
25
25
  "@types/ssh2": "^1.15.4",
26
+ "@vitest/coverage-v8": "4.1.9",
26
27
  "tsup": "^8.4.0",
27
28
  "typescript": "^6.0.3",
28
29
  "vitest": "^4.1.7"
@@ -56,9 +57,10 @@
56
57
  "scripts": {
57
58
  "build": "tsup",
58
59
  "test": "pnpm test:unit && pnpm test:dist",
60
+ "test:coverage": "vitest run --coverage",
59
61
  "test:dist": "pnpm build && vitest run --config vitest.distribution.config.ts",
60
62
  "test:integration": "pnpm build && vitest run --config vitest.integration.config.ts",
61
- "test:unit": "vitest run",
63
+ "test:unit": "vitest run --coverage",
62
64
  "test:watch": "vitest"
63
65
  }
64
66
  }