paratix 0.13.0 → 0.15.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 CHANGED
@@ -119,19 +119,21 @@ Paratix can also manage file-backed swap directly. Use `swap.file(...)` to provi
119
119
 
120
120
  ## CLI
121
121
 
122
+ `paratix --help` lists these options directly, so they are discoverable without drilling into `paratix apply --help` first.
123
+
122
124
  ```text
123
125
  paratix apply <file> [options]
124
126
 
125
127
  Options:
126
- --diff
127
- --dry-run
128
- --env <key=value>
129
- --env-file <path>
130
- --filter <names>
131
- --first-run
132
- --reconnect-timeout <seconds>
133
- --verbose
134
- --help
128
+ --diff Combined with --dry-run: unified diff per changed module
129
+ --dry-run Only check, do not apply (some runtime restarts stay unverified)
130
+ --env <key=value...> Set environment values for the playbook (repeatable)
131
+ --env-file <path> Load environment values from a dotenv file
132
+ --filter <names> Run only the named recipes/modules (comma-separated, repeatable)
133
+ --first-run Set PARATIX_FIRST_RUN=true before loading the playbook
134
+ --reconnect-timeout <seconds> SSH reconnect timeout for reboots/port changes (max 86400)
135
+ --verbose Show full stack traces on error
136
+ --help Show help
135
137
  ```
136
138
 
137
139
  `--filter <names>` restricts the run to the named recipes and modules. Names are matched anywhere in the tree, and every node that is not selected is shown as `skipped` instead of being executed. The option is comma-separated and repeatable, so `--filter rybbit,palamedes-examples` and `--filter rybbit --filter palamedes-examples` are equivalent. Selecting a recipe runs its whole subtree; a recipe that is not selected but contains a selected descendant is still descended into, so only the matching children run while its siblings are skipped. If several nodes share the same name, every one of them is selected. An unknown filter name aborts the run before it connects (exit code 2). The final run summary counts only top-level nodes, so nested skipped nodes are still shown but are not added to the `skipped` tally. `--filter` composes with `--dry-run` and the other flags.
@@ -145,7 +147,9 @@ Options:
145
147
  ## Documentation
146
148
 
147
149
  - For project scaffolding, see [`create-paratix`](https://www.npmjs.com/package/create-paratix).
148
- - For detailed authoring guidance and module reference inside this repo, see [llm-guide.md](./llm-guide.md).
150
+ - See the complete [TypeScript API and Module Reference](./llm-guide.md).
151
+ - For common runtime and connection failures, see [Troubleshooting](../../docs/user-guide/troubleshooting.md).
152
+ - Before upgrading an existing project, see the [migration notes](../../docs/user-guide/migration.md).
149
153
 
150
154
  ## License
151
155
 
@@ -3245,6 +3245,15 @@ async function commandCheckPassed(ssh2, check, secrets) {
3245
3245
  });
3246
3246
  return result.code === 0;
3247
3247
  }
3248
+ function commandExecOptions(secrets, options) {
3249
+ const execOptions2 = {
3250
+ ignoreExitCode: true,
3251
+ secrets,
3252
+ silent: true
3253
+ };
3254
+ if (options?.timeout !== void 0) execOptions2.timeout = options.timeout;
3255
+ return execOptions2;
3256
+ }
3248
3257
  var command = {
3249
3258
  /**
3250
3259
  * Run an arbitrary shell command on the remote host.
@@ -3264,6 +3273,7 @@ var command = {
3264
3273
  * If it exits `0`, the command is considered already done.
3265
3274
  * @param options.name - An optional display name shown in the run output.
3266
3275
  * @param options.secrets - Secret values that must be redacted from command output.
3276
+ * @param options.timeout - Maximum runtime for the command in milliseconds.
3267
3277
  * @returns A Module that executes the shell command.
3268
3278
  *
3269
3279
  * @example
@@ -3285,11 +3295,7 @@ var command = {
3285
3295
  if (options?.check != null && await commandCheckPassed(ssh2, options.check, secrets)) {
3286
3296
  return { status: "ok" };
3287
3297
  }
3288
- const result = await ssh2.exec(cmd, {
3289
- ignoreExitCode: true,
3290
- secrets,
3291
- silent: true
3292
- });
3298
+ const result = await ssh2.exec(cmd, commandExecOptions(secrets, options));
3293
3299
  if (result.code !== 0) {
3294
3300
  return failedCommand(`[${moduleName}] command failed`, {
3295
3301
  ...result,
@@ -10535,18 +10541,19 @@ var op = {
10535
10541
  }
10536
10542
  };
10537
10543
 
10538
- // src/modules/package.ts
10539
- var EXEC_OPTS12 = { ignoreExitCode: true, silent: true };
10540
- function execOptions(options) {
10541
- if (options?.timeout === void 0) return EXEC_OPTS12;
10542
- return { ...EXEC_OPTS12, timeout: options.timeout };
10544
+ // src/modules/packageVersion.ts
10545
+ var VERSION_EXEC_OPTS = { ignoreExitCode: true, silent: true };
10546
+ function isPackageSpec(value) {
10547
+ return "name" in value && typeof value.name === "string";
10543
10548
  }
10544
10549
  function splitPackagesAndOptions(values) {
10545
10550
  const packages = [];
10546
10551
  let options;
10547
10552
  for (const [index, value] of values.entries()) {
10548
10553
  if (typeof value === "string") {
10549
- packages.push(value);
10554
+ packages.push({ name: value, version: void 0 });
10555
+ } else if (isPackageSpec(value)) {
10556
+ packages.push({ name: value.name, version: value.version });
10550
10557
  } else if (index === values.length - 1) {
10551
10558
  options = value;
10552
10559
  }
@@ -10554,16 +10561,135 @@ function splitPackagesAndOptions(values) {
10554
10561
  return { options, packages };
10555
10562
  }
10556
10563
  var PACKAGE_NAME_PATTERN = new RegExp("^[a-z0-9][a-z0-9+._\\-]*[a-z0-9.]$", "v");
10557
- function validatePackageNames(moduleName, packages) {
10564
+ var PACKAGE_VERSION_PATTERN = new RegExp("^[A-Za-z0-9][\\w.+~:\\-]*$", "v");
10565
+ function isValidPackageName(packageName) {
10566
+ const isSingleAlnum = packageName.length === 1 && new RegExp("^[a-z0-9]$", "v").test(packageName);
10567
+ return isSingleAlnum || PACKAGE_NAME_PATTERN.test(packageName);
10568
+ }
10569
+ function validatePackages(moduleName, packages) {
10558
10570
  if (packages.length === 0) {
10559
10571
  throw new Error(`${moduleName}: at least one package name is required`);
10560
10572
  }
10561
- for (const packageName of packages) {
10562
- const isSingleAlnum = packageName.length === 1 && new RegExp("^[a-z0-9]$", "v").test(packageName);
10563
- if (!isSingleAlnum && !PACKAGE_NAME_PATTERN.test(packageName)) {
10564
- throw new Error(`${moduleName}: invalid package name ${JSON.stringify(packageName)}`);
10573
+ const seenVersions = /* @__PURE__ */ new Map();
10574
+ for (const { name, version } of packages) {
10575
+ if (!isValidPackageName(name)) {
10576
+ throw new Error(`${moduleName}: invalid package name ${JSON.stringify(name)}`);
10577
+ }
10578
+ if (version !== void 0 && !PACKAGE_VERSION_PATTERN.test(version)) {
10579
+ throw new Error(
10580
+ `${moduleName}: invalid package version ${JSON.stringify(version)} for ${JSON.stringify(name)}`
10581
+ );
10582
+ }
10583
+ if (seenVersions.has(name) && seenVersions.get(name) !== version) {
10584
+ throw new Error(
10585
+ `${moduleName}: conflicting versions requested for package ${JSON.stringify(name)}`
10586
+ );
10565
10587
  }
10588
+ seenVersions.set(name, version);
10589
+ }
10590
+ }
10591
+ function installToken(pm, packageName, version) {
10592
+ if (version === void 0) return shellQuote(packageName);
10593
+ const separator = pm === "dnf" || pm === "yum" ? "-" : "=";
10594
+ return shellQuote(`${packageName}${separator}${version}`);
10595
+ }
10596
+ function parseApkVersion(out, packageName) {
10597
+ if (out.length === 0) return null;
10598
+ const firstToken = out.split(new RegExp("\\s+", "v"))[0] ?? "";
10599
+ const prefix = `${packageName}-`;
10600
+ if (!firstToken.startsWith(prefix)) return null;
10601
+ const version = firstToken.slice(prefix.length);
10602
+ return version.length > 0 ? version : null;
10603
+ }
10604
+ async function getInstalledVersion(ssh2, pm, packageName) {
10605
+ const quoted = shellQuote(packageName);
10606
+ if (pm === "apk") {
10607
+ const raw = await ssh2.output(`apk version -v ${quoted}`);
10608
+ return parseApkVersion(raw.trim(), packageName);
10609
+ }
10610
+ if (pm === "apt") {
10611
+ const raw = await ssh2.output(`dpkg-query -W -f='\${Version}' ${quoted} 2>/dev/null`);
10612
+ const aptVersion = raw.trim();
10613
+ return aptVersion.length > 0 ? aptVersion : null;
10614
+ }
10615
+ const result = await ssh2.exec(
10616
+ `rpm -q --qf '%|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\\n' ${quoted}`,
10617
+ VERSION_EXEC_OPTS
10618
+ );
10619
+ if (result.code !== 0) return null;
10620
+ const lines = result.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
10621
+ return lines.at(-1) ?? null;
10622
+ }
10623
+ async function versionsEqual(parameters) {
10624
+ const { installed, pinned, pm, ssh: ssh2 } = parameters;
10625
+ if (pm === "apt") {
10626
+ return ssh2.test(`dpkg --compare-versions ${shellQuote(installed)} eq ${shellQuote(pinned)}`);
10566
10627
  }
10628
+ return installed === pinned;
10629
+ }
10630
+ function describePackages(packages) {
10631
+ return packages.map((p) => p.version === void 0 ? p.name : `${p.name}=${p.version}`).join(", ");
10632
+ }
10633
+ function aptInstallCommand(tokens, hasPin) {
10634
+ return hasPin ? `DEBIAN_FRONTEND=noninteractive apt-get install -y --allow-downgrades -- ${tokens}` : `DEBIAN_FRONTEND=noninteractive apt-get install -y -- ${tokens}`;
10635
+ }
10636
+ var DOWNGRADE_COMMANDS = {
10637
+ dnf: (tokens) => `dnf downgrade -y -- ${tokens}`,
10638
+ yum: (tokens) => `yum downgrade -y -- ${tokens}`
10639
+ };
10640
+ function buildInstallCommand(baseInstall, pm, packages) {
10641
+ const tokens = packages.map((p) => installToken(pm, p.name, p.version)).join(" ");
10642
+ const hasPin = packages.some((p) => p.version !== void 0);
10643
+ return pm === "apt" ? aptInstallCommand(tokens, hasPin) : baseInstall(tokens);
10644
+ }
10645
+ async function installedIsHigher(ssh2, installed, pinned) {
10646
+ if (installed === pinned) return false;
10647
+ const result = await ssh2.exec(
10648
+ `printf '%s\\n%s\\n' ${shellQuote(pinned)} ${shellQuote(installed)} | sort -V | tail -n1`,
10649
+ VERSION_EXEC_OPTS
10650
+ );
10651
+ if (result.code !== 0) return false;
10652
+ return result.stdout.trim() === installed;
10653
+ }
10654
+ async function collectDowngradeTokens(ssh2, pm, packages) {
10655
+ const downgrade = [];
10656
+ const install = [];
10657
+ for (const package_ of packages) {
10658
+ if (package_.version === void 0) {
10659
+ install.push(package_);
10660
+ continue;
10661
+ }
10662
+ const installed = await getInstalledVersion(ssh2, pm, package_.name);
10663
+ if (installed !== null && await installedIsHigher(ssh2, installed, package_.version)) {
10664
+ downgrade.push(installToken(pm, package_.name, package_.version));
10665
+ } else {
10666
+ install.push(package_);
10667
+ }
10668
+ }
10669
+ return { downgrade, install };
10670
+ }
10671
+ async function runVersionedInstall(parameters) {
10672
+ const { baseInstall, execOpts, label, packages, pm, ssh: ssh2 } = parameters;
10673
+ const failure = (result) => failedCommand(`[package.installed: ${label}] package installation failed`, result);
10674
+ let installTargets = packages;
10675
+ if (pm === "dnf" || pm === "yum") {
10676
+ const split = await collectDowngradeTokens(ssh2, pm, packages);
10677
+ installTargets = split.install;
10678
+ if (split.downgrade.length > 0) {
10679
+ const down = await ssh2.exec(DOWNGRADE_COMMANDS[pm](split.downgrade.join(" ")), execOpts);
10680
+ if (down.code !== 0) return failure(down);
10681
+ }
10682
+ }
10683
+ if (installTargets.length === 0) return null;
10684
+ const install = await ssh2.exec(buildInstallCommand(baseInstall, pm, installTargets), execOpts);
10685
+ return install.code === 0 ? null : failure(install);
10686
+ }
10687
+
10688
+ // src/modules/package.ts
10689
+ var EXEC_OPTS12 = { ignoreExitCode: true, silent: true };
10690
+ function execOptions(options) {
10691
+ if (options?.timeout === void 0) return EXEC_OPTS12;
10692
+ return { ...EXEC_OPTS12, timeout: options.timeout };
10567
10693
  }
10568
10694
  var pmCache = /* @__PURE__ */ new WeakMap();
10569
10695
  var INSTALL_COMMANDS = {
@@ -10629,35 +10755,45 @@ async function isPackageInstalled(ssh2, pm, packageName) {
10629
10755
  }
10630
10756
  }
10631
10757
  }
10758
+ async function isPackageSatisfied(ssh2, pm, target) {
10759
+ if (target.version === void 0) {
10760
+ return isPackageInstalled(ssh2, pm, target.name);
10761
+ }
10762
+ const installed = await getInstalledVersion(ssh2, pm, target.name);
10763
+ if (installed === null) return false;
10764
+ return versionsEqual({ installed, pinned: target.version, pm, ssh: ssh2 });
10765
+ }
10632
10766
  async function hasAnyMissingPackage(ssh2, pm, packages) {
10633
- for (const packageName of packages) {
10634
- if (!await isPackageInstalled(ssh2, pm, packageName)) return true;
10767
+ for (const package_ of packages) {
10768
+ if (!await isPackageSatisfied(ssh2, pm, package_)) return true;
10635
10769
  }
10636
10770
  return false;
10637
10771
  }
10638
10772
  async function collectStillMissingPackages(ssh2, pm, packages) {
10639
10773
  const stillMissing = [];
10640
- for (const verifyName of packages) {
10641
- if (!await isPackageInstalled(ssh2, pm, verifyName)) {
10642
- stillMissing.push(verifyName);
10774
+ for (const package_ of packages) {
10775
+ if (!await isPackageSatisfied(ssh2, pm, package_)) {
10776
+ stillMissing.push(package_.name);
10643
10777
  }
10644
10778
  }
10645
10779
  return stillMissing;
10646
10780
  }
10647
10781
  async function runInstallAndVerify(parameters) {
10648
10782
  const { options, packages, pm, ssh: ssh2 } = parameters;
10649
- const quoted = packages.map((p) => shellQuote(p)).join(" ");
10650
- const result = await ssh2.exec(INSTALL_COMMANDS[pm](quoted), execOptions(options));
10651
- if (result.code !== 0) {
10652
- return failedCommand(
10653
- `[package.installed: ${packages.join(", ")}] package installation failed`,
10654
- result
10655
- );
10656
- }
10783
+ const label = describePackages(packages);
10784
+ const failure = await runVersionedInstall({
10785
+ baseInstall: INSTALL_COMMANDS[pm],
10786
+ execOpts: execOptions(options),
10787
+ label,
10788
+ packages,
10789
+ pm,
10790
+ ssh: ssh2
10791
+ });
10792
+ if (failure) return failure;
10657
10793
  const stillMissing = await collectStillMissingPackages(ssh2, pm, packages);
10658
10794
  if (stillMissing.length > 0) {
10659
10795
  return failed(
10660
- `[package.installed: ${packages.join(", ")}] packages still missing after install: ${stillMissing.join(", ")}`
10796
+ `[package.installed: ${label}] packages still missing after install: ${stillMissing.join(", ")}`
10661
10797
  );
10662
10798
  }
10663
10799
  return { status: "changed" };
@@ -10672,8 +10808,9 @@ var pkg = {
10672
10808
  * Pass an `UpgradeOptions` object as the last argument to override the SSH
10673
10809
  * timeout for slow remove operations.
10674
10810
  *
10675
- * @param packagesAndOptions - One or more package names, optionally followed
10676
- * by an `UpgradeOptions` object as the last argument.
10811
+ * @param packagesAndOptions - One or more package names (bare strings or
10812
+ * `PackageSpec` objects without a `version`), optionally followed by an
10813
+ * `UpgradeOptions` object as the last argument.
10677
10814
  * @returns A Module that removes the packages if any are present.
10678
10815
  *
10679
10816
  * @example
@@ -10682,28 +10819,33 @@ var pkg = {
10682
10819
  */
10683
10820
  absent(...packagesAndOptions) {
10684
10821
  const { options, packages } = splitPackagesAndOptions(packagesAndOptions);
10685
- validatePackageNames("package.absent", packages);
10822
+ validatePackages("package.absent", packages);
10823
+ for (const p of packages) {
10824
+ if (p.version !== void 0) {
10825
+ throw new Error(
10826
+ `package.absent: version pinning is not supported (package ${JSON.stringify(p.name)})`
10827
+ );
10828
+ }
10829
+ }
10830
+ const names = packages.map((p) => p.name);
10831
+ const label = names.join(", ");
10686
10832
  return {
10687
10833
  async apply(ssh2) {
10688
- if (!ssh2)
10689
- return failed(`[package.absent: ${packages.join(", ")}] SSH connection is required`);
10834
+ if (!ssh2) return failed(`[package.absent: ${label}] SSH connection is required`);
10690
10835
  const pm = await detectPackageManager(ssh2);
10691
- if (!pm) return missingPackageManager(`package.absent: ${packages.join(", ")}`);
10836
+ if (!pm) return missingPackageManager(`package.absent: ${label}`);
10692
10837
  let anyInstalled = false;
10693
- for (const packageName of packages) {
10838
+ for (const packageName of names) {
10694
10839
  if (await isPackageInstalled(ssh2, pm, packageName)) {
10695
10840
  anyInstalled = true;
10696
10841
  break;
10697
10842
  }
10698
10843
  }
10699
10844
  if (!anyInstalled) return { status: "ok" };
10700
- const quoted = packages.map((p) => shellQuote(p)).join(" ");
10845
+ const quoted = names.map((p) => shellQuote(p)).join(" ");
10701
10846
  const result = await ssh2.exec(REMOVE_COMMANDS[pm](quoted), execOptions(options));
10702
10847
  if (result.code !== 0) {
10703
- return failedCommand(
10704
- `[package.absent: ${packages.join(", ")}] package removal failed`,
10705
- result
10706
- );
10848
+ return failedCommand(`[package.absent: ${label}] package removal failed`, result);
10707
10849
  }
10708
10850
  return { status: "changed" };
10709
10851
  },
@@ -10711,12 +10853,12 @@ var pkg = {
10711
10853
  if (!ssh2) return NEEDS_APPLY;
10712
10854
  const pm = await detectPackageManager(ssh2);
10713
10855
  if (!pm) return NEEDS_APPLY;
10714
- for (const p of packages) {
10856
+ for (const p of names) {
10715
10857
  if (await isPackageInstalled(ssh2, pm, p)) return NEEDS_APPLY;
10716
10858
  }
10717
10859
  return "ok";
10718
10860
  },
10719
- name: `package.absent: ${packages.join(", ")}`
10861
+ name: `package.absent: ${label}`
10720
10862
  };
10721
10863
  },
10722
10864
  /**
@@ -10728,24 +10870,32 @@ var pkg = {
10728
10870
  * Pass an `UpgradeOptions` object as the last argument to override the SSH
10729
10871
  * timeout for slow install operations.
10730
10872
  *
10731
- * @param packagesAndOptions - One or more package names, optionally followed
10732
- * by an `UpgradeOptions` object as the last argument.
10873
+ * Pass a `PackageSpec` (`{ name, version }`) to pin a package to an exact
10874
+ * version; `check` then reports drift against the installed version and
10875
+ * `apply` converges on the pin, including a downgrade. No package holds are
10876
+ * created — only the requested version is installed.
10877
+ *
10878
+ * @param packagesAndOptions - One or more packages (bare name strings or
10879
+ * `PackageSpec` objects), optionally followed by an `UpgradeOptions` object
10880
+ * as the last argument.
10733
10881
  * @returns A Module that installs missing packages.
10734
10882
  *
10735
10883
  * @example
10736
10884
  * pkg.installed("git", "curl", "unzip")
10737
10885
  * pkg.installed("texlive-full", { timeout: 900_000 })
10886
+ * pkg.installed({ name: "grafana", version: "13.1.0" })
10738
10887
  */
10739
10888
  installed(...packagesAndOptions) {
10740
10889
  const { options, packages } = splitPackagesAndOptions(packagesAndOptions);
10741
- validatePackageNames("package.installed", packages);
10890
+ validatePackages("package.installed", packages);
10891
+ const label = describePackages(packages);
10742
10892
  return {
10743
10893
  async apply(ssh2) {
10744
10894
  if (!ssh2) {
10745
- return failed(`[package.installed: ${packages.join(", ")}] SSH connection is required`);
10895
+ return failed(`[package.installed: ${label}] SSH connection is required`);
10746
10896
  }
10747
10897
  const pm = await detectPackageManager(ssh2);
10748
- if (!pm) return missingPackageManager(`package.installed: ${packages.join(", ")}`);
10898
+ if (!pm) return missingPackageManager(`package.installed: ${label}`);
10749
10899
  const anyMissing = await hasAnyMissingPackage(ssh2, pm, packages);
10750
10900
  if (!anyMissing) return { status: "ok" };
10751
10901
  return runInstallAndVerify({ options, packages, pm, ssh: ssh2 });
@@ -10755,11 +10905,11 @@ var pkg = {
10755
10905
  const pm = await detectPackageManager(ssh2);
10756
10906
  if (!pm) return NEEDS_APPLY;
10757
10907
  for (const p of packages) {
10758
- if (!await isPackageInstalled(ssh2, pm, p)) return NEEDS_APPLY;
10908
+ if (!await isPackageSatisfied(ssh2, pm, p)) return NEEDS_APPLY;
10759
10909
  }
10760
10910
  return "ok";
10761
10911
  },
10762
- name: `package.installed: ${packages.join(", ")}`
10912
+ name: `package.installed: ${label}`
10763
10913
  };
10764
10914
  },
10765
10915
  /**
package/dist/cli.js CHANGED
@@ -1547,6 +1547,10 @@ var DEFAULT_TERMINAL_COLUMNS = 100;
1547
1547
  var MIN_PACKAGE_COLUMNS_WIDTH = 24;
1548
1548
  var MIN_PACKAGE_COLUMN_WIDTH = 18;
1549
1549
  var MIN_ANIMATED_LINE_COLUMNS = 8;
1550
+ var ELAPSED_DISPLAY_THRESHOLD_MS = 1e3;
1551
+ var MILLISECONDS_PER_SECOND = 1e3;
1552
+ var MILLISECONDS_PER_MINUTE = 6e4;
1553
+ var SECONDS_PER_MINUTE = 60;
1550
1554
  function splitPackageModuleName(name) {
1551
1555
  for (const prefix of ["package.installed: ", "package.absent: "]) {
1552
1556
  if (!name.startsWith(prefix)) continue;
@@ -1584,6 +1588,16 @@ function getPackageSummaryDetail(packageCount, detail) {
1584
1588
  const packageLabel = packageCount === 1 ? "1 package" : `${packageCount} packages`;
1585
1589
  return detail == null ? packageLabel : `${packageLabel} \xB7 ${detail}`;
1586
1590
  }
1591
+ function formatModuleElapsed(elapsedMs) {
1592
+ if (!Number.isFinite(elapsedMs) || elapsedMs < ELAPSED_DISPLAY_THRESHOLD_MS) return void 0;
1593
+ if (elapsedMs < MILLISECONDS_PER_MINUTE) {
1594
+ return `${(elapsedMs / MILLISECONDS_PER_SECOND).toFixed(1)}s`;
1595
+ }
1596
+ const totalSeconds = Math.floor(elapsedMs / MILLISECONDS_PER_SECOND);
1597
+ const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
1598
+ const seconds = totalSeconds % SECONDS_PER_MINUTE;
1599
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
1600
+ }
1587
1601
  function fitAnimatedModuleLine(line, columns) {
1588
1602
  if (columns === void 0 || columns < MIN_ANIMATED_LINE_COLUMNS) return line;
1589
1603
  const plainLine = stripVTControlCharacters(line);
@@ -1835,6 +1849,7 @@ function getSharedLiveOutputState() {
1835
1849
  const existing = registry[LIVE_OUTPUT_STATE_KEY];
1836
1850
  if (existing != null) return existing;
1837
1851
  const created = {
1852
+ activeModuleStartedAt: null,
1838
1853
  activeRecipeGuideDepths: [],
1839
1854
  activeSpinner: null,
1840
1855
  cursorHidden: false,
@@ -1885,7 +1900,7 @@ function getGuideDot(depth) {
1885
1900
  return depth % 2 === 0 ? pc.gray("\xB7") : pc.cyan("\xB7");
1886
1901
  }
1887
1902
  function buildGuideIndent(baseIndent, options) {
1888
- const indentCharacters = Array.from(baseIndent);
1903
+ const indentCharacters = [...baseIndent];
1889
1904
  const guideDepths = [
1890
1905
  ...options?.activeGuideDepths ?? liveOutputState.activeRecipeGuideDepths,
1891
1906
  ...options?.extraGuideDepths ?? []
@@ -1921,20 +1936,27 @@ async function withRecipeOutputScope(scopedOperation) {
1921
1936
  try {
1922
1937
  return await scopedOperation();
1923
1938
  } finally {
1924
- liveOutputState.activeRecipeGuideDepths = liveOutputState.activeRecipeGuideDepths.filter((depth) => depth !== liveOutputState.recipeOutputDepth);
1939
+ liveOutputState.activeRecipeGuideDepths = liveOutputState.activeRecipeGuideDepths.filter(
1940
+ (depth) => depth !== liveOutputState.recipeOutputDepth
1941
+ );
1925
1942
  liveOutputState.pendingRecipeClosureGuideDepths = [liveOutputState.recipeOutputDepth];
1926
1943
  liveOutputState.recipeOutputDepth -= 1;
1927
1944
  }
1928
1945
  }
1946
+ function getActiveModuleElapsed() {
1947
+ if (liveOutputState.activeModuleStartedAt == null) return void 0;
1948
+ return formatModuleElapsed(Date.now() - liveOutputState.activeModuleStartedAt);
1949
+ }
1929
1950
  function renderModuleLine(parameters) {
1930
- const { detail, extraGuideDepths = [], name, status, waitingFrame } = parameters;
1951
+ const { detail, elapsed, extraGuideDepths = [], name, status, waitingFrame } = parameters;
1931
1952
  const baseIndent = getModuleIndent();
1932
1953
  const indent = buildGuideIndent(baseIndent, { extraGuideDepths });
1933
1954
  const icon = getModuleIcon(status, waitingFrame);
1934
1955
  const statusText = getModuleStatusText(status);
1935
1956
  const detailSuffix = detail == null ? "" : ` ${pc.dim(detail)}`;
1957
+ const elapsedSuffix = elapsed == null ? "" : ` ${pc.dim(elapsed)}`;
1936
1958
  const alignedNameWidth = Math.max(MODULE_NAME_WIDTH - baseIndent.length, MIN_MODULE_NAME_WIDTH);
1937
- return `${indent}${icon} ${name.padEnd(alignedNameWidth)} ${statusText}${detailSuffix}`;
1959
+ return `${indent}${icon} ${name.padEnd(alignedNameWidth)} ${statusText}${detailSuffix}${elapsedSuffix}`;
1938
1960
  }
1939
1961
  function hideCursor() {
1940
1962
  if (liveOutputState.cursorHidden) return;
@@ -1967,6 +1989,7 @@ function stopLiveModuleOutput(clearCurrentLine = false) {
1967
1989
  stopAnimatedModuleLine(clearCurrentLine);
1968
1990
  }
1969
1991
  function startModuleSpinner(name, detail) {
1992
+ liveOutputState.activeModuleStartedAt = Date.now();
1970
1993
  if (!supportsAnimatedModuleOutput()) return;
1971
1994
  clearPendingRecipeClosureGuides();
1972
1995
  stopAnimatedModuleLine();
@@ -1987,6 +2010,7 @@ function startModuleSpinner(name, detail) {
1987
2010
  writeAnimatedModuleLine(
1988
2011
  renderModuleLine({
1989
2012
  detail: spinner.detail,
2013
+ elapsed: getActiveModuleElapsed(),
1990
2014
  name: displayModule.name,
1991
2015
  status: "waiting",
1992
2016
  waitingFrame: SPINNER_FRAMES[spinner.frameIndex]
@@ -1999,6 +2023,7 @@ function startModuleSpinner(name, detail) {
1999
2023
  writeAnimatedModuleLine(
2000
2024
  renderModuleLine({
2001
2025
  detail: displayModule.detail,
2026
+ elapsed: getActiveModuleElapsed(),
2002
2027
  name: displayModule.name,
2003
2028
  status: "waiting",
2004
2029
  waitingFrame: SPINNER_FRAMES[0]
@@ -2011,7 +2036,10 @@ function printRecipeHeader(name) {
2011
2036
  const header = pc.bold(pc.blue(`[${name}]`));
2012
2037
  console.log(`${buildGuideIndent(getRecipeHeaderIndent())}${header}`);
2013
2038
  if (liveOutputState.recipeOutputDepth >= 0) {
2014
- liveOutputState.activeRecipeGuideDepths = [...liveOutputState.activeRecipeGuideDepths, liveOutputState.recipeOutputDepth];
2039
+ liveOutputState.activeRecipeGuideDepths = [
2040
+ ...liveOutputState.activeRecipeGuideDepths,
2041
+ liveOutputState.recipeOutputDepth
2042
+ ];
2015
2043
  }
2016
2044
  }
2017
2045
  function printRunContext(parameters) {
@@ -2065,8 +2093,11 @@ function printRenderedModuleResult(parameters) {
2065
2093
  status: parameters.status,
2066
2094
  terminalColumns: process.stdout.columns
2067
2095
  });
2096
+ const elapsed = getActiveModuleElapsed();
2097
+ liveOutputState.activeModuleStartedAt = null;
2068
2098
  const line = renderModuleLine({
2069
2099
  detail: displayModule.detail,
2100
+ elapsed,
2070
2101
  extraGuideDepths,
2071
2102
  name: displayModule.name,
2072
2103
  status: parameters.status
@@ -2193,7 +2224,9 @@ function printCauseChain(error) {
2193
2224
  if (visited.has(cause)) return;
2194
2225
  visited.add(cause);
2195
2226
  }
2196
- console.error(pc.red(`${getErrorIndent()}Cause: ${maskRegisteredSecrets(formatCauseValue(cause))}`));
2227
+ console.error(
2228
+ pc.red(`${getErrorIndent()}Cause: ${maskRegisteredSecrets(formatCauseValue(cause))}`)
2229
+ );
2197
2230
  if (cause instanceof CommandError) {
2198
2231
  printVerboseCommandError(
2199
2232
  maskRegisteredSecrets(cause.fullStdout),
@@ -6229,7 +6262,7 @@ async function runApplyCommand(file, options, run = runPlaybook) {
6229
6262
  if (options.diff && !options.dryRun) {
6230
6263
  throw new CliUsageError("--diff requires --dry-run");
6231
6264
  }
6232
- printCliHeader("0.13.0-33ef8ce");
6265
+ printCliHeader("0.15.0-645d2fd");
6233
6266
  const environmentOverrides = applyCliEnvironmentOverrides(options.env, {
6234
6267
  firstRun: options.firstRun
6235
6268
  });
@@ -6259,9 +6292,16 @@ function exitAfterApplyError(error, verbose) {
6259
6292
  const exitCode = process.exitCode === void 0 || process.exitCode === 0 ? 2 : process.exitCode;
6260
6293
  process.exit(exitCode);
6261
6294
  }
6295
+ function renderSubcommandOptionsHelp(command) {
6296
+ const flagsWidth = Math.max(...command.options.map((option) => option.flags.length));
6297
+ const rows = command.options.map(
6298
+ (option) => ` ${option.flags.padEnd(flagsWidth)} ${option.description}`
6299
+ );
6300
+ return ["", `Options for "paratix ${command.name()} <file>":`, ...rows].join("\n");
6301
+ }
6262
6302
  var program = new Command();
6263
- program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.13.0-33ef8ce");
6264
- program.command("apply <file>").description("Apply a server definition").option(
6303
+ program.name("paratix").description("Idempotent VPS setup tool in TypeScript").version("0.15.0-645d2fd");
6304
+ var applyCommand = program.command("apply <file>").description("Apply a server definition").option(
6265
6305
  "--diff",
6266
6306
  "When combined with --dry-run, show a unified diff per module that would change.",
6267
6307
  false
@@ -6269,7 +6309,12 @@ program.command("apply <file>").description("Apply a server definition").option(
6269
6309
  "--dry-run",
6270
6310
  "Only check, do not apply. Some modules validate prospective config but cannot verify runtime restarts.",
6271
6311
  false
6272
- ).option("--env <key=value...>", "Set env values", collectEnvironment, {}).option("--env-file <path>", "Load dotenv file").option(
6312
+ ).option(
6313
+ "--env <key=value...>",
6314
+ "Set environment values passed to the playbook (repeatable; key=value).",
6315
+ collectEnvironment,
6316
+ {}
6317
+ ).option("--env-file <path>", "Load environment values from a dotenv file.").option(
6273
6318
  "--filter <names>",
6274
6319
  "Run only the named recipes/modules (comma-separated, repeatable). Every other node is shown as skipped.",
6275
6320
  collectFilter,
@@ -6302,6 +6347,7 @@ program.command("apply <file>").description("Apply a server definition").option(
6302
6347
  exitAfterApplyError(error, options.verbose);
6303
6348
  }
6304
6349
  });
6350
+ program.addHelpText("after", () => renderSubcommandOptionsHelp(applyCommand));
6305
6351
  function parsePositiveNumber(value, options = {}) {
6306
6352
  const parsed = Number(value);
6307
6353
  if (!Number.isInteger(parsed) || parsed <= 0) {
@@ -6396,6 +6442,7 @@ export {
6396
6442
  parsePositiveNumber,
6397
6443
  parseReconnectTimeoutSeconds,
6398
6444
  printExceptionError,
6445
+ renderSubcommandOptionsHelp,
6399
6446
  resetLastResortHandlerForTests,
6400
6447
  resetTsxRegistrationForTests,
6401
6448
  resolveFilteredRun,
@@ -339,6 +339,27 @@ type UpgradeOptions = {
339
339
  /** Override the SSH layer's command timeout (milliseconds). */
340
340
  timeout?: number;
341
341
  };
342
+ /**
343
+ * A package to install, optionally pinned to an exact version.
344
+ *
345
+ * A bare string package name is equivalent to `{ name }` (no version pin). The
346
+ * package-manager-specific version syntax (`name=version`, `name-version`) is
347
+ * never accepted as a pass-through string — it is built from the structured
348
+ * `name`/`version` fields so the raw PM syntax can never leak into a playbook.
349
+ */
350
+ type PackageSpec = {
351
+ /** Package name (validated against {@link PACKAGE_NAME_PATTERN}). */
352
+ name: string;
353
+ /**
354
+ * Exact version to pin to. When set, `check` reports drift against the
355
+ * installed version and `apply` converges on this version (including a
356
+ * downgrade). When omitted, only presence is enforced.
357
+ */
358
+ version?: string;
359
+ };
360
+ /** Argument accepted by the variadic `installed`/`absent` package methods. */
361
+ type PackageArgument = PackageSpec | string | UpgradeOptions;
362
+
342
363
  /**
343
364
  * Distro-agnostic package management module.
344
365
  *
@@ -367,15 +388,16 @@ declare const pkg: {
367
388
  * Pass an `UpgradeOptions` object as the last argument to override the SSH
368
389
  * timeout for slow remove operations.
369
390
  *
370
- * @param packagesAndOptions - One or more package names, optionally followed
371
- * by an `UpgradeOptions` object as the last argument.
391
+ * @param packagesAndOptions - One or more package names (bare strings or
392
+ * `PackageSpec` objects without a `version`), optionally followed by an
393
+ * `UpgradeOptions` object as the last argument.
372
394
  * @returns A Module that removes the packages if any are present.
373
395
  *
374
396
  * @example
375
397
  * pkg.absent("vim", "nano")
376
398
  * pkg.absent("vim", "nano", { timeout: 600_000 })
377
399
  */
378
- absent(...packagesAndOptions: Array<string | UpgradeOptions>): Module;
400
+ absent(...packagesAndOptions: PackageArgument[]): Module;
379
401
  /**
380
402
  * Ensure the given packages are installed.
381
403
  *
@@ -385,15 +407,22 @@ declare const pkg: {
385
407
  * Pass an `UpgradeOptions` object as the last argument to override the SSH
386
408
  * timeout for slow install operations.
387
409
  *
388
- * @param packagesAndOptions - One or more package names, optionally followed
389
- * by an `UpgradeOptions` object as the last argument.
410
+ * Pass a `PackageSpec` (`{ name, version }`) to pin a package to an exact
411
+ * version; `check` then reports drift against the installed version and
412
+ * `apply` converges on the pin, including a downgrade. No package holds are
413
+ * created — only the requested version is installed.
414
+ *
415
+ * @param packagesAndOptions - One or more packages (bare name strings or
416
+ * `PackageSpec` objects), optionally followed by an `UpgradeOptions` object
417
+ * as the last argument.
390
418
  * @returns A Module that installs missing packages.
391
419
  *
392
420
  * @example
393
421
  * pkg.installed("git", "curl", "unzip")
394
422
  * pkg.installed("texlive-full", { timeout: 900_000 })
423
+ * pkg.installed({ name: "grafana", version: "13.1.0" })
395
424
  */
396
- installed(...packagesAndOptions: Array<string | UpgradeOptions>): Module;
425
+ installed(...packagesAndOptions: PackageArgument[]): Module;
397
426
  /**
398
427
  * Refresh the package manager's package lists once per dated flag.
399
428
  *
@@ -583,6 +612,7 @@ declare const command: {
583
612
  * If it exits `0`, the command is considered already done.
584
613
  * @param options.name - An optional display name shown in the run output.
585
614
  * @param options.secrets - Secret values that must be redacted from command output.
615
+ * @param options.timeout - Maximum runtime for the command in milliseconds.
586
616
  * @returns A Module that executes the shell command.
587
617
  *
588
618
  * @example
@@ -595,6 +625,7 @@ declare const command: {
595
625
  check?: string;
596
626
  name?: string;
597
627
  secrets?: string[];
628
+ timeout?: number;
598
629
  }): Module;
599
630
  };
600
631
 
@@ -2106,4 +2137,4 @@ declare const user: {
2106
2137
  present(name: string, options?: UserOptions): Module;
2107
2138
  };
2108
2139
 
2109
- export { op as A, pkg as B, quadlet as C, releaseUpgrade as D, type Environment as E, rsync as F, script as G, service as H, ssh as I, sshd as J, swap as K, sysctl as L, type Module as M, NEEDS_APPLY as N, type OrchestrationStep as O, system as P, systemd as Q, timer as R, type SshdPortMetaEntry as S, ufw as T, user as U, type UpgradeOptions as V, type ModuleMetaEntry as a, type MetaEnvironmentValue as b, type EnvironmentMetaEntry as c, type SystemHostMetaEntry as d, type SystemRebootMetaEntry as e, type ModuleResult as f, type ExecResult as g, type ModuleStatus as h, type SshConnection as i, type ShutdownSignal as j, type ServerDefinition as k, type EnvironmentValue as l, type ExecOptions as m, type SshConfig as n, apt as o, archive as p, command as q, compose as r, cron as s, download as t, file as u, git as v, group as w, hostname as x, mount as y, net as z };
2140
+ export { op as A, pkg as B, quadlet as C, releaseUpgrade as D, type Environment as E, rsync as F, script as G, service as H, ssh as I, sshd as J, swap as K, sysctl as L, type Module as M, NEEDS_APPLY as N, type OrchestrationStep as O, system as P, systemd as Q, timer as R, type SshdPortMetaEntry as S, ufw as T, user as U, type PackageSpec as V, type UpgradeOptions as W, type ModuleMetaEntry as a, type MetaEnvironmentValue as b, type EnvironmentMetaEntry as c, type SystemHostMetaEntry as d, type SystemRebootMetaEntry as e, type ModuleResult as f, type ExecResult as g, type ModuleStatus as h, type SshConnection as i, type ShutdownSignal as j, type ServerDefinition as k, type EnvironmentValue as l, type ExecOptions as m, type SshConfig as n, apt as o, archive as p, command as q, compose as r, cron as s, download as t, file as u, git as v, group as w, hostname as x, mount as y, net as z };
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-DwKdTyHo.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-DwKdTyHo.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-Da9ccU5K.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-Da9ccU5K.js';
3
3
 
4
4
  /**
5
5
  * Fail the run if a condition on the current env is not satisfied.
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ import {
61
61
  user,
62
62
  validateHostLabel,
63
63
  validateSshConfig
64
- } from "./chunk-XIRORNO3.js";
64
+ } from "./chunk-GB5QZWUM.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),
@@ -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-DwKdTyHo.js';
1
+ export { V as PackageSpec, W 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-Da9ccU5K.js';
@@ -27,7 +27,7 @@ import {
27
27
  timer,
28
28
  ufw,
29
29
  user
30
- } from "../chunk-XIRORNO3.js";
30
+ } from "../chunk-GB5QZWUM.js";
31
31
  export {
32
32
  apt,
33
33
  archive,
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
 
@@ -267,12 +277,12 @@ that are expected to respond more slowly.
267
277
 
268
278
  Import with renaming: `import { package as pkg } from "paratix/modules"`. The word `package` is reserved in JavaScript, so you must alias it.
269
279
 
270
- | Method | Signature | Idempotent |
271
- | ------------------- | ------------------------------------------------------------------ | -------------------- |
272
- | `package.installed` | `(...packagesAndOptions: Array<string \| UpgradeOptions>): Module` | Yes |
273
- | `package.absent` | `(...packagesAndOptions: Array<string \| UpgradeOptions>): Module` | Yes |
274
- | `package.update` | `(date: string, options?: UpgradeOptions): Module` | Yes (versioned flag) |
275
- | `package.upgrade` | `(date: string, options?: UpgradeOptions): Module` | Yes (versioned flag) |
280
+ | Method | Signature | Idempotent |
281
+ | ------------------- | --------------------------------------------------------------------------------- | -------------------- |
282
+ | `package.installed` | `(...packagesAndOptions: Array<string \| PackageSpec \| UpgradeOptions>): Module` | Yes |
283
+ | `package.absent` | `(...packagesAndOptions: Array<string \| PackageSpec \| UpgradeOptions>): Module` | Yes |
284
+ | `package.update` | `(date: string, options?: UpgradeOptions): Module` | Yes (versioned flag) |
285
+ | `package.upgrade` | `(date: string, options?: UpgradeOptions): Module` | Yes (versioned flag) |
276
286
 
277
287
  #### `UpgradeOptions`
278
288
 
@@ -283,10 +293,10 @@ type UpgradeOptions = {
283
293
  }
284
294
  ```
285
295
 
286
- For `package.installed` / `package.absent`, pass the options object as the **last** argument
287
- after the package names; existing variadic call sites such as `pkg.installed("git", "curl")`
288
- keep working unchanged. Use a longer `timeout` for slow operations on production servers
289
- with large update backlogs:
296
+ Für `package.installed` / `package.absent` wird das Options-Objekt als **letztes** Argument
297
+ nach den Paketnamen übergeben; bestehende variadische Aufrufe wie `pkg.installed("git", "curl")`
298
+ funktionieren unverändert weiter. Verwende einen höheren `timeout` für langsame Operationen auf
299
+ Produktionsservern mit großem Update-Rückstand:
290
300
 
291
301
  ```typescript
292
302
  pkg.upgrade("2026-05-01", { timeout: 900_000 })
@@ -294,6 +304,57 @@ pkg.installed("texlive-full", { timeout: 900_000 })
294
304
  apt.distUpgrade("2026-05-01", { timeout: 1_200_000 })
295
305
  ```
296
306
 
307
+ #### `PackageSpec` — Versions-Pinning
308
+
309
+ ```typescript
310
+ type PackageSpec = {
311
+ name: string
312
+ version?: string // Exakte Zielversion. Ohne `version` wird nur die Anwesenheit erzwungen.
313
+ }
314
+ ```
315
+
316
+ Ein Paket kann auf eine exakte Version festgenagelt werden, indem statt eines Strings ein
317
+ `PackageSpec`-Objekt übergeben wird. Ein bloßer String `"grafana"` ist äquivalent zu
318
+ `{ name: "grafana" }` (kein Pin). Die paketmanager-spezifische Syntax (`name=version`,
319
+ `name-version`) wird **niemals** als Pass-through-String akzeptiert — ein String mit `=` bleibt
320
+ ein ungültiger Paketname. Die Argumente werden über ihre **Form** unterschieden, nicht über die
321
+ Position: ein Objekt **mit** `name`-Feld ist ein `PackageSpec` (darf auch an letzter Stelle
322
+ stehen), ein Objekt **ohne** `name`-Feld an letzter Position ist das `UpgradeOptions`-Objekt.
323
+
324
+ ```typescript
325
+ pkg.installed({ name: "grafana", version: "13.1.0" })
326
+ pkg.installed("curl", { name: "grafana", version: "13.1.0" }, { timeout: 600_000 })
327
+ ```
328
+
329
+ Übersetzung des Install-Tokens je Paketmanager:
330
+
331
+ | Paketmanager | Install-Token | Beispiel |
332
+ | ------------ | -------------- | ---------------------- |
333
+ | apt | `name=version` | `grafana=13.1.0` |
334
+ | apk | `name=version` | `grafana=13.1.0-r0` |
335
+ | dnf / yum | `name-version` | `grafana-13.1.0-1.el9` |
336
+
337
+ Semantik:
338
+
339
+ - **`check`** vergleicht die installierte gegen die gepinnte Version; jede Abweichung ergibt
340
+ `needs-apply`. Ohne gepinnte Version bleibt es eine reine Anwesenheitsprüfung.
341
+ - **`apply`** installiert exakt die gepinnte Version — auch als **Downgrade**. Für apt wird bei
342
+ gepinnter Version automatisch `--allow-downgrades` ergänzt; für dnf/yum wird bei einer bereits
343
+ installierten höheren Version `dnf downgrade` / `yum downgrade` statt `install` verwendet
344
+ (apk installiert die exakte Version auch abwärts ohne Extra-Flag). Die Richtungsbestimmung für
345
+ dnf/yum nutzt einen versionsbewussten Vergleich auf dem Zielhost (`sort -V`), damit mehrstellige
346
+ Komponenten wie `13.9.0` vs. `13.10.0` korrekt geordnet werden.
347
+ - Nach der Installation wird zusätzlich die Version verifiziert: eine Teil- oder Falschversions-
348
+ Installation meldet `failed`, nicht `changed`.
349
+ - **Kein Hold.** Es wird ausschließlich die Version installiert — es werden **keine**
350
+ `apt-mark hold`-Marker und **keine** `preferences.d`-Dateien angelegt. Ohne einen erneuten
351
+ Lauf mit gepinnter Version kann ein späterer `apt upgrade` das Paket also wieder anheben.
352
+
353
+ Gültige Versionen beginnen alphanumerisch und dürfen `[A-Za-z0-9]` sowie `. + ~ : _ -` enthalten
354
+ (deckt apt-Epoch/Revision wie `1:2.3-1ubuntu0.2` und rpm-`version-release` wie `13.1.0-1.el9` ab).
355
+ Whitespace und Shell-Metazeichen werden abgelehnt. `package.absent` ist rein namensbasiert; ein
356
+ `PackageSpec` mit gesetzter `version` führt dort zu einem klaren Fehler.
357
+
297
358
  ### `quadlet`
298
359
 
299
360
  | Method | Signature | Idempotent |
@@ -342,7 +403,7 @@ rsync SSH process and does not depend on a local `known_hosts` entry.
342
403
  | `ssh.authorizedKeys` | `(user: string, key: string, options?: { state?: "absent" \| "present" }): Module` | Yes |
343
404
  | `ssh.knownHosts` | `(host: string, options?: { expectedFingerprint?: string; port?: number; publicKey?: string; state?: "absent" \| "present" }): Module` | Yes |
344
405
 
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.
406
+ **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
407
 
347
408
  ### `sshd`
348
409
 
@@ -666,7 +727,7 @@ fail("This branch should be unreachable")
666
727
 
667
728
  ### `firstRun.stop(message?)`
668
729
 
669
- Stoppt den aktuellen Lauf kontrolliert, wenn Paratix mit `--first-run` gestartet wurde. Nützlich als explizite Staging-Grenze in Bootstrap-Playbooks.
730
+ Stops the current run cleanly when Paratix was started with `--first-run`. Useful as an explicit staging boundary in bootstrap playbooks.
670
731
 
671
732
  ```typescript
672
733
  firstRun.stop("Bootstrap foundation complete; rerun without --first-run to continue.")
@@ -674,7 +735,7 @@ firstRun.stop("Bootstrap foundation complete; rerun without --first-run to conti
674
735
 
675
736
  ### `signals.flush(message?)`
676
737
 
677
- Führt alle aktuell offenen Signale des aktiven Scopes sofort aus und setzt deren Pending-Zustand zurück.
738
+ Immediately runs all currently pending signals in the active scope and clears their pending state.
678
739
 
679
740
  ```typescript
680
741
  signals.flush("Reload services before the next bootstrap stage")
@@ -742,20 +803,20 @@ Rules:
742
803
 
743
804
  Diff-producing built-in modules:
744
805
 
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. |
806
+ | Module | Diff content |
807
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
808
+ | `file.copy` | Unified diff between the remote file and the local source. |
809
+ | `file.template` | Unified diff between the remote file and the rendered template. |
810
+ | `sysctl.set` | `key = old → new` for the desired configuration. |
811
+ | `hostname.set` | `hostname = old → new`. |
812
+ | `swap.file` | Diff of the `/etc/fstab` entry (or removal of the line for `state: "absent"`). |
813
+ | `swap.swappiness` | `vm.swappiness = old → new` (via `sysctl.set`). |
814
+ | `swap.vfsCachePressure` | `vm.vfs_cache_pressure = old → new` (via `sysctl.set`). |
815
+ | `cron.job` / `cron.absent` | Unified diff of the target user's crontab. |
816
+ | `timer.scheduled` (present) | Concatenated diffs of the `.service` and `.timer` unit files. |
817
+ | `timer.scheduled` (absent) / `timer.absent` | List of unit files to remove. |
818
+ | `net.hosts` | Unified diff of `/etc/hosts`. |
819
+ | `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
820
 
760
821
  ### Implementing a Diff for a Custom Module
761
822
 
@@ -810,7 +871,7 @@ The diff string is plain text — no ANSI codes. The output layer applies colors
810
871
  4. For idempotency with `command.shell()`, always provide a `check` command.
811
872
  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
873
  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.
874
+ 7. Use `signals.flush()` only as an explicit checkpoint when staged flows require an early signal flush.
814
875
  8. Always pass a date string to `package.upgrade()` and `package.update()` -- it is the idempotency key.
815
876
  9. Specify `ssh.ports` as an array -- the runner tries each port in order.
816
877
  10. Custom modules must implement both `check` and `apply`, both async.
@@ -831,9 +892,9 @@ The diff string is plain text — no ANSI codes. The output layer applies colors
831
892
  9. Do NOT use template syntax `{{key}}` in TypeScript code -- templates are only for files rendered via `file.template()`.
832
893
  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
894
  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`
895
+ 12. `signals.flush()` always flushes only the current scope:
896
+ - inside a recipe, that recipe's signals
897
+ - at the top level, `server(...).signals`
837
898
  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
899
  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
900
  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.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Idempotent VPS setup tool in TypeScript",
5
5
  "type": "module",
6
6
  "files": [