paratix 0.13.0 → 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.
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,
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.14.0-5a61f29");
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.14.0-5a61f29");
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,
@@ -583,6 +583,7 @@ declare const command: {
583
583
  * If it exits `0`, the command is considered already done.
584
584
  * @param options.name - An optional display name shown in the run output.
585
585
  * @param options.secrets - Secret values that must be redacted from command output.
586
+ * @param options.timeout - Maximum runtime for the command in milliseconds.
586
587
  * @returns A Module that executes the shell command.
587
588
  *
588
589
  * @example
@@ -595,6 +596,7 @@ declare const command: {
595
596
  check?: string;
596
597
  name?: string;
597
598
  secrets?: string[];
599
+ timeout?: number;
598
600
  }): Module;
599
601
  };
600
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-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-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.
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-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),
@@ -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 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-XIRORNO3.js";
30
+ } from "../chunk-475OT32R.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
 
@@ -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
 
@@ -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.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Idempotent VPS setup tool in TypeScript",
5
5
  "type": "module",
6
6
  "files": [