paratix 0.12.6 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -157,6 +157,17 @@ type Module = {
157
157
  * @returns `"ok"` if the desired state is already present, `"needs-apply"` otherwise.
158
158
  */
159
159
  check: (ssh: null | SshConnection, environment: Environment) => Promise<"needs-apply" | "ok">;
160
+ /**
161
+ * Internal discriminator that distinguishes leaf modules from recipes.
162
+ *
163
+ * Leaf modules leave this `undefined` (treated as `"module"`); recipes set
164
+ * `"recipe"` so the runner can narrow to the recipe contract via the
165
+ * `isRecipe` type guard instead of an unsafe structural cast. Intentionally
166
+ * optional so existing custom modules and playbooks stay source-compatible —
167
+ * they never need to set it.
168
+ * @internal
169
+ */
170
+ kind?: "module" | "recipe";
160
171
  /**
161
172
  * When true the module runs locally instead of over SSH.
162
173
  * The `ssh` parameter will be `null` in check/apply.
@@ -240,8 +251,16 @@ type SshConnection = {
240
251
  probeSudo: () => Promise<void>;
241
252
  /** Read the full contents of a remote file as a string. */
242
253
  readFile: (remotePath: string) => Promise<string>;
243
- /** Reconnect the SSH session using the current host and registered port candidates. */
244
- reconnect: () => Promise<void>;
254
+ /**
255
+ * Reconnect the SSH session using the current host and registered port
256
+ * candidates. An explicit `reconnectTimeout` in the SSH config always takes
257
+ * precedence; when it is unset, `options.defaultTimeout` (in milliseconds)
258
+ * overrides the generic reconnect default for this call — used by the reboot
259
+ * path to grant a longer window.
260
+ */
261
+ reconnect: (options?: {
262
+ defaultTimeout?: number;
263
+ }) => Promise<void>;
245
264
  /** Remove a previously registered port from the reconnect candidate list. */
246
265
  removePort: (port: number) => void;
247
266
  /** Return the SHA-256 hex digest of a remote file, or `null` if not found. */
@@ -250,13 +269,13 @@ type SshConnection = {
250
269
  test: (command: string) => Promise<boolean>;
251
270
  /** Update the target host address (e.g. after a reboot with new IP). */
252
271
  updateHost: (host: string) => void;
253
- /** Upload a local file to the remote host via SFTP. */
272
+ /** Upload a local file to the remote host via SFTP. `options.mode` is optional and defaults to `"0600"`. */
254
273
  uploadFile: (localPath: string, remotePath: string, options?: {
255
274
  mode?: string;
256
275
  }) => Promise<void>;
257
- /** Write a string to a remote file, creating or overwriting it. */
258
- writeFile: (remotePath: string, content: string, options: {
259
- mode: string;
276
+ /** Write a string to a remote file, creating or overwriting it. `options.mode` is optional and defaults to `"0600"`. */
277
+ writeFile: (remotePath: string, content: string, options?: {
278
+ mode?: string;
260
279
  }) => Promise<void>;
261
280
  };
262
281
  /** SSH connection parameters for a server. */
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { E as Environment, M as Module, a as ModuleMetaEntry, b as MetaEnvironmentValue, c as EnvironmentMetaEntry, S as SshdPortMetaEntry, d as SystemHostMetaEntry, e as SystemRebootMetaEntry, f as ModuleResult, g as ExecResult, h as ModuleStatus, i as SshConnection, O as OrchestrationStep, j as ShutdownSignal, k as ServerDefinition } from './index-udpAybq3.js';
2
- export { l as EnvironmentValue, m as ExecOptions, N as NEEDS_APPLY, n as SshConfig, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from './index-udpAybq3.js';
1
+ import { E as Environment, M as Module, a as ModuleMetaEntry, b as MetaEnvironmentValue, c as EnvironmentMetaEntry, S as SshdPortMetaEntry, d as SystemHostMetaEntry, e as SystemRebootMetaEntry, f as ModuleResult, g as ExecResult, h as ModuleStatus, i as SshConnection, O as OrchestrationStep, j as ShutdownSignal, k as ServerDefinition } from './index-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';
3
3
 
4
4
  /**
5
5
  * Fail the run if a condition on the current env is not satisfied.
@@ -185,11 +185,16 @@ type SignalHooks = {
185
185
 
186
186
  /**
187
187
  * Internal representation of a recipe module.
188
- * The `_isRecipe` flag lets the runner distinguish recipes from leaf modules.
188
+ *
189
+ * The `kind: "recipe"` discriminator lets the runner distinguish recipes from
190
+ * leaf modules through the {@link isRecipe} type guard, forming a discriminated
191
+ * union with plain {@link Module} values (which leave `kind` unset). The child
192
+ * `_modules` and optional `_signals` are recipe-specific data that TypeScript
193
+ * narrows to once `isRecipe` confirmed the discriminator — no structural cast
194
+ * is required anywhere.
189
195
  * @internal
190
196
  */
191
197
  type RecipeModule = {
192
- _isRecipe: true;
193
198
  _modules: Module[];
194
199
  _signals?: Module[];
195
200
  apply: (ssh: null | SshConnection, environment: Environment, options?: {
@@ -199,6 +204,7 @@ type RecipeModule = {
199
204
  signalHooks?: SignalHooks;
200
205
  verbose?: boolean;
201
206
  }) => Promise<ModuleResult>;
207
+ kind: "recipe";
202
208
  } & Module;
203
209
  /**
204
210
  * Group a list of modules into a named, self-contained recipe.
@@ -215,8 +221,11 @@ type RecipeModule = {
215
221
  * @returns A RecipeModule that groups the child modules.
216
222
  *
217
223
  * @example
224
+ * import { recipe } from "paratix"
225
+ * import { package as pkg, file, service } from "paratix/modules"
226
+ *
218
227
  * export const nginxRecipe = recipe("nginx", [
219
- * apt.installed("nginx"),
228
+ * pkg.installed("nginx"),
220
229
  * file.template("/etc/nginx/nginx.conf", "./files/nginx.conf.tmpl"),
221
230
  * service.enabled("nginx"),
222
231
  * ], {
@@ -239,11 +248,14 @@ declare function recipe(name: string, modules: Module[], options?: {
239
248
  * @throws {Error} When any required field is missing or empty.
240
249
  *
241
250
  * @example
251
+ * import { server } from "paratix"
252
+ * import { package as pkg } from "paratix/modules"
253
+ *
242
254
  * export default server({
243
255
  * name: "web-01",
244
256
  * host: "10.0.0.1",
245
257
  * ssh: { user: "root", ports: [22], privateKey: "~/.ssh/id_ed25519" }, // "~" is expanded
246
- * run: [apt.installed("nginx")],
258
+ * run: [pkg.installed("nginx")],
247
259
  * });
248
260
  */
249
261
  declare function server(config: ServerDefinition): ServerDefinition;
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ import {
61
61
  user,
62
62
  validateHostLabel,
63
63
  validateSshConfig
64
- } from "./chunk-CWGQ2MYN.js";
64
+ } from "./chunk-XIRORNO3.js";
65
65
 
66
66
  // src/conditionalModules.ts
67
67
  function createConditionalApplyState(environment) {
@@ -607,6 +607,9 @@ var MODULE_NAME_WIDTH = 56;
607
607
  var MIN_MODULE_NAME_WIDTH = 12;
608
608
  var OUTPUT_INDENT_UNIT = " ";
609
609
  var SPINNER_FRAME_INTERVAL_MS = 80;
610
+ var ASCII_ESC = 27;
611
+ var ANSI_HIDE_CURSOR = `${String.fromCharCode(ASCII_ESC)}[?25l`;
612
+ var ANSI_SHOW_CURSOR = `${String.fromCharCode(ASCII_ESC)}[?25h`;
610
613
  var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
611
614
  var STATUS_ICONS = {
612
615
  changed: pc.yellow("\u21BA"),
@@ -615,10 +618,22 @@ var STATUS_ICONS = {
615
618
  skipped: pc.dim("\u2298"),
616
619
  waiting: pc.cyan("\u23F8")
617
620
  };
618
- var activeSpinner = null;
619
- var activeRecipeGuideDepths = [];
620
- var pendingRecipeClosureGuideDepths = [];
621
- var recipeOutputDepth = -1;
621
+ var LIVE_OUTPUT_STATE_KEY = /* @__PURE__ */ Symbol.for("paratix.output.liveState");
622
+ function getSharedLiveOutputState() {
623
+ const registry = globalThis;
624
+ const existing = registry[LIVE_OUTPUT_STATE_KEY];
625
+ if (existing != null) return existing;
626
+ const created = {
627
+ activeRecipeGuideDepths: [],
628
+ activeSpinner: null,
629
+ cursorHidden: false,
630
+ pendingRecipeClosureGuideDepths: [],
631
+ recipeOutputDepth: -1
632
+ };
633
+ registry[LIVE_OUTPUT_STATE_KEY] = created;
634
+ return created;
635
+ }
636
+ var liveOutputState = getSharedLiveOutputState();
622
637
  function supportsAnimatedModuleOutput() {
623
638
  return process.stdout.isTTY && typeof process.stdout.clearLine === "function" && typeof process.stdout.cursorTo === "function";
624
639
  }
@@ -645,7 +660,7 @@ function getModuleStatusText(status) {
645
660
  }
646
661
  }
647
662
  function getCurrentOutputDepth() {
648
- return Math.max(recipeOutputDepth, 0);
663
+ return Math.max(liveOutputState.recipeOutputDepth, 0);
649
664
  }
650
665
  function getGuideDot(depth) {
651
666
  return depth % 2 === 0 ? pc.gray("\xB7") : pc.cyan("\xB7");
@@ -653,7 +668,7 @@ function getGuideDot(depth) {
653
668
  function buildGuideIndent(baseIndent, options) {
654
669
  const indentCharacters = Array.from(baseIndent);
655
670
  const guideDepths = [
656
- ...options?.activeGuideDepths ?? activeRecipeGuideDepths,
671
+ ...options?.activeGuideDepths ?? liveOutputState.activeRecipeGuideDepths,
657
672
  ...options?.extraGuideDepths ?? []
658
673
  ];
659
674
  for (const guideDepth of guideDepths) {
@@ -664,16 +679,16 @@ function buildGuideIndent(baseIndent, options) {
664
679
  return indentCharacters.join("");
665
680
  }
666
681
  function clearPendingRecipeClosureGuides() {
667
- pendingRecipeClosureGuideDepths = [];
682
+ liveOutputState.pendingRecipeClosureGuideDepths = [];
668
683
  }
669
684
  function getModuleIndent() {
670
- if (recipeOutputDepth < 0) {
685
+ if (liveOutputState.recipeOutputDepth < 0) {
671
686
  return OUTPUT_INDENT_UNIT;
672
687
  }
673
688
  return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 2);
674
689
  }
675
690
  function getRecipeHeaderIndent() {
676
- if (recipeOutputDepth < 0) return "";
691
+ if (liveOutputState.recipeOutputDepth < 0) return "";
677
692
  return OUTPUT_INDENT_UNIT.repeat(getCurrentOutputDepth() + 1);
678
693
  }
679
694
  function getErrorIndent() {
@@ -683,13 +698,13 @@ function getContinuationIndent() {
683
698
  return `${getModuleIndent()} `;
684
699
  }
685
700
  async function withRecipeOutputScope(scopedOperation) {
686
- recipeOutputDepth += 1;
701
+ liveOutputState.recipeOutputDepth += 1;
687
702
  try {
688
703
  return await scopedOperation();
689
704
  } finally {
690
- activeRecipeGuideDepths = activeRecipeGuideDepths.filter((depth) => depth !== recipeOutputDepth);
691
- pendingRecipeClosureGuideDepths = [recipeOutputDepth];
692
- recipeOutputDepth -= 1;
705
+ liveOutputState.activeRecipeGuideDepths = liveOutputState.activeRecipeGuideDepths.filter((depth) => depth !== liveOutputState.recipeOutputDepth);
706
+ liveOutputState.pendingRecipeClosureGuideDepths = [liveOutputState.recipeOutputDepth];
707
+ liveOutputState.recipeOutputDepth -= 1;
693
708
  }
694
709
  }
695
710
  function renderModuleLine(parameters) {
@@ -702,15 +717,28 @@ function renderModuleLine(parameters) {
702
717
  const alignedNameWidth = Math.max(MODULE_NAME_WIDTH - baseIndent.length, MIN_MODULE_NAME_WIDTH);
703
718
  return `${indent}${icon} ${name.padEnd(alignedNameWidth)} ${statusText}${detailSuffix}`;
704
719
  }
720
+ function hideCursor() {
721
+ if (liveOutputState.cursorHidden) return;
722
+ if (!supportsAnimatedModuleOutput()) return;
723
+ process.stdout.write(ANSI_HIDE_CURSOR);
724
+ liveOutputState.cursorHidden = true;
725
+ }
726
+ function showCursor() {
727
+ if (!liveOutputState.cursorHidden) return;
728
+ process.stdout.write(ANSI_SHOW_CURSOR);
729
+ liveOutputState.cursorHidden = false;
730
+ }
705
731
  function writeAnimatedModuleLine(line) {
732
+ hideCursor();
706
733
  process.stdout.clearLine(0);
707
734
  process.stdout.cursorTo(0);
708
735
  process.stdout.write(fitAnimatedModuleLine(line, process.stdout.columns));
709
736
  }
710
737
  function stopAnimatedModuleLine(clearCurrentLine = false) {
711
- if (activeSpinner == null) return;
712
- clearInterval(activeSpinner.interval);
713
- activeSpinner = null;
738
+ showCursor();
739
+ if (liveOutputState.activeSpinner == null) return;
740
+ clearInterval(liveOutputState.activeSpinner.interval);
741
+ liveOutputState.activeSpinner = null;
714
742
  if (clearCurrentLine && supportsAnimatedModuleOutput()) {
715
743
  process.stdout.clearLine(0);
716
744
  process.stdout.cursorTo(0);
@@ -745,7 +773,7 @@ function startModuleSpinner(name, detail) {
745
773
  }, SPINNER_FRAME_INTERVAL_MS)
746
774
  };
747
775
  if (typeof spinner.interval.unref === "function") spinner.interval.unref();
748
- activeSpinner = spinner;
776
+ liveOutputState.activeSpinner = spinner;
749
777
  writeAnimatedModuleLine(
750
778
  renderModuleLine({
751
779
  detail: displayModule.detail,
@@ -760,8 +788,8 @@ function printRecipeHeader(name) {
760
788
  clearPendingRecipeClosureGuides();
761
789
  const header = pc.bold(pc.blue(`[${name}]`));
762
790
  console.log(`${buildGuideIndent(getRecipeHeaderIndent())}${header}`);
763
- if (recipeOutputDepth >= 0) {
764
- activeRecipeGuideDepths = [...activeRecipeGuideDepths, recipeOutputDepth];
791
+ if (liveOutputState.recipeOutputDepth >= 0) {
792
+ liveOutputState.activeRecipeGuideDepths = [...liveOutputState.activeRecipeGuideDepths, liveOutputState.recipeOutputDepth];
765
793
  }
766
794
  }
767
795
  function colorizeDiffLine(line) {
@@ -815,7 +843,7 @@ function printRenderedModuleResult(parameters) {
815
843
  status: parameters.status
816
844
  });
817
845
  const diffLines = parameters.diff == null ? [] : renderDiffLines(parameters.diff);
818
- const usesSpinner = supportsAnimatedModuleOutput() && activeSpinner != null;
846
+ const usesSpinner = supportsAnimatedModuleOutput() && liveOutputState.activeSpinner != null;
819
847
  if (usesSpinner) {
820
848
  stopAnimatedModuleLine();
821
849
  writeAnimatedModuleLine(line);
@@ -838,7 +866,7 @@ function printRecipeModuleResult(name, status, detail, diff) {
838
866
  printRenderedModuleResult({
839
867
  detail,
840
868
  diff,
841
- extraGuideDepths: pendingRecipeClosureGuideDepths,
869
+ extraGuideDepths: liveOutputState.pendingRecipeClosureGuideDepths,
842
870
  name,
843
871
  status
844
872
  });
@@ -1163,11 +1191,11 @@ async function runSignalModules(parameters) {
1163
1191
 
1164
1192
  // src/recipe.ts
1165
1193
  var INTERRUPTED_BEFORE_APPLY = /* @__PURE__ */ Symbol("recipe-interrupted-before-apply");
1166
- function isRecipeModuleLike(module) {
1167
- return module._isRecipe === true;
1194
+ function isRecipe(module) {
1195
+ return module.kind === "recipe";
1168
1196
  }
1169
1197
  function printRecipeChildResult(module, result) {
1170
- if (isRecipeModuleLike(module)) {
1198
+ if (isRecipe(module)) {
1171
1199
  printRecipeModuleResult(module.name, result.status, result.detail);
1172
1200
  return;
1173
1201
  }
@@ -1234,7 +1262,7 @@ async function checkRecipeChild(targetModule, connection, currentEnvironment) {
1234
1262
  return targetModule.check(connection, currentEnvironment);
1235
1263
  }
1236
1264
  async function applyRecipeChild(parameters) {
1237
- if (isRecipeModuleLike(parameters.targetModule)) {
1265
+ if (isRecipe(parameters.targetModule)) {
1238
1266
  return parameters.targetModule.apply(parameters.connection, parameters.currentEnvironment, {
1239
1267
  onChildStep: parameters.onChildStep,
1240
1268
  onSignalStep: parameters.onSignalStep,
@@ -1441,7 +1469,6 @@ function createRecipeDryRunApply(name, modules, needsDryRunApply) {
1441
1469
  const result = await dryRunRecipeModule({
1442
1470
  environment,
1443
1471
  recipeModule: {
1444
- _isRecipe: true,
1445
1472
  _modules: modules,
1446
1473
  async apply() {
1447
1474
  await Promise.resolve();
@@ -1451,6 +1478,7 @@ function createRecipeDryRunApply(name, modules, needsDryRunApply) {
1451
1478
  await Promise.resolve();
1452
1479
  return "ok";
1453
1480
  },
1481
+ kind: "recipe",
1454
1482
  name
1455
1483
  },
1456
1484
  shutdownSignal: parameters?.shutdownSignal,
@@ -1470,7 +1498,6 @@ function recipe(name, modules, options) {
1470
1498
  ...modules.some((module) => module._dryRunBlocker === true) ? { _dryRunBlocker: true } : {},
1471
1499
  ...modules.some((module) => module._dryRunMetaProducer === true) ? { _dryRunMetaProducer: true } : {},
1472
1500
  ...applyDryRun == null ? {} : { _applyDryRun: applyDryRun },
1473
- _isRecipe: true,
1474
1501
  _modules: modules,
1475
1502
  _signals: options?.signals,
1476
1503
  _supportsChildStepHook: true,
@@ -1498,6 +1525,7 @@ function recipe(name, modules, options) {
1498
1525
  }
1499
1526
  return "ok";
1500
1527
  },
1528
+ kind: "recipe",
1501
1529
  name
1502
1530
  };
1503
1531
  }
@@ -1606,4 +1634,3 @@ export {
1606
1634
  user,
1607
1635
  when
1608
1636
  };
1609
- //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- export { V as UpgradeOptions, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from '../index-udpAybq3.js';
1
+ export { V as UpgradeOptions, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from '../index-DwKdTyHo.js';
@@ -27,7 +27,7 @@ import {
27
27
  timer,
28
28
  ufw,
29
29
  user
30
- } from "../chunk-CWGQ2MYN.js";
30
+ } from "../chunk-XIRORNO3.js";
31
31
  export {
32
32
  apt,
33
33
  archive,
@@ -58,4 +58,3 @@ export {
58
58
  ufw,
59
59
  user
60
60
  };
61
- //# sourceMappingURL=index.js.map
package/llm-guide.md CHANGED
@@ -481,23 +481,23 @@ function myCustomModule(configPath: string, content: string): Module {
481
481
 
482
482
  Methods available on the `ssh` parameter:
483
483
 
484
- | Method | Return type | Description |
485
- | ------------------------------------------------ | --------------------------------------- | ---------------------------------------------------------------------------------------------- |
486
- | `ssh.exec(cmd, options?)` | `Promise<ExecResult>` | Run command, get `{ code, stdout, stderr }`. Throws on non-zero unless `ignoreExitCode: true`. |
487
- | `ssh.test(cmd)` | `Promise<boolean>` | Run command, return `true` if exit code is 0. |
488
- | `ssh.output(cmd)` | `Promise<string>` | Run command, return trimmed stdout. |
489
- | `ssh.lines(cmd)` | `Promise<string[]>` | Run command, return stdout split into lines. |
490
- | `ssh.exists(path)` | `Promise<boolean>` | Check if remote path exists. |
491
- | `ssh.readFile(path)` | `Promise<string>` | Read remote file content. |
492
- | `ssh.writeFile(path, content, { mode: "0644" })` | `Promise<void>` | Write content to remote file. `mode` is required; choose an explicit file mode. |
493
- | `ssh.uploadFile(local, remote)` | `Promise<void>` | Upload local file via SFTP. |
494
- | `ssh.downloadFile(remote, local)` | `Promise<void>` | Download remote file. |
495
- | `ssh.sha256(path)` | `Promise<string \| null>` | Get SHA-256 hex digest, or null if not found. |
496
- | `ssh.addPort(port)` | `void` | Register an additional port opened on the remote host (advanced). |
497
- | `ssh.disconnect()` | `void` | Close the SSH connection. |
498
- | `ssh.getConnectionInfo()` | `{ host, port, privateKeyPath?, user }` | Return current connection parameters. |
499
- | `ssh.probeSudo()` | `Promise<void>` | Probe/cache sudo access; prompts interactively if needed. |
500
- | `ssh.updateHost(host)` | `void` | Update the target host address (e.g., after IP change). |
484
+ | Method | Return type | Description |
485
+ | ------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------- |
486
+ | `ssh.exec(cmd, options?)` | `Promise<ExecResult>` | Run command, get `{ code, stdout, stderr }`. Throws on non-zero unless `ignoreExitCode: true`. |
487
+ | `ssh.test(cmd)` | `Promise<boolean>` | Run command, return `true` if exit code is 0. |
488
+ | `ssh.output(cmd)` | `Promise<string>` | Run command, return trimmed stdout. |
489
+ | `ssh.lines(cmd)` | `Promise<string[]>` | Run command, return stdout split into lines. |
490
+ | `ssh.exists(path)` | `Promise<boolean>` | Check if remote path exists. |
491
+ | `ssh.readFile(path)` | `Promise<string>` | Read remote file content. |
492
+ | `ssh.writeFile(path, content, { mode: "0644" })` | `Promise<void>` | Write content to remote file. `mode` is optional and defaults to `"0600"`. |
493
+ | `ssh.uploadFile(local, remote, { mode: "0644" })` | `Promise<void>` | Upload local file via SFTP. `mode` is optional and defaults to `"0600"`. |
494
+ | `ssh.downloadFile(remote, local)` | `Promise<void>` | Download remote file. |
495
+ | `ssh.sha256(path)` | `Promise<string \| null>` | Get SHA-256 hex digest, or null if not found. |
496
+ | `ssh.addPort(port)` | `void` | Register an additional port opened on the remote host (advanced). |
497
+ | `ssh.disconnect()` | `void` | Close the SSH connection. |
498
+ | `ssh.getConnectionInfo()` | `{ host, port, privateKeyPath?, user }` | Return current connection parameters. |
499
+ | `ssh.probeSudo()` | `Promise<void>` | Probe/cache sudo access; prompts interactively if needed. |
500
+ | `ssh.updateHost(host)` | `void` | Update the target host address (e.g., after IP change). |
501
501
 
502
502
  ### ExecOptions
503
503
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paratix",
3
- "version": "0.12.6",
3
+ "version": "0.13.0",
4
4
  "description": "Idempotent VPS setup tool in TypeScript",
5
5
  "type": "module",
6
6
  "files": [
@@ -21,8 +21,9 @@
21
21
  "tsx": "^4.22.3"
22
22
  },
23
23
  "devDependencies": {
24
- "@types/node": "24.12.4",
24
+ "@types/node": "24.13.2",
25
25
  "@types/ssh2": "^1.15.4",
26
+ "@vitest/coverage-v8": "4.1.9",
26
27
  "tsup": "^8.4.0",
27
28
  "typescript": "^6.0.3",
28
29
  "vitest": "^4.1.7"
@@ -56,9 +57,10 @@
56
57
  "scripts": {
57
58
  "build": "tsup",
58
59
  "test": "pnpm test:unit && pnpm test:dist",
60
+ "test:coverage": "vitest run --coverage",
59
61
  "test:dist": "pnpm build && vitest run --config vitest.distribution.config.ts",
60
62
  "test:integration": "pnpm build && vitest run --config vitest.integration.config.ts",
61
- "test:unit": "vitest run",
63
+ "test:unit": "vitest run --coverage",
62
64
  "test:watch": "vitest"
63
65
  }
64
66
  }