paratix 0.12.8 → 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-M3ZQJNKM.js";
64
+ } from "./chunk-XIRORNO3.js";
65
65
 
66
66
  // src/conditionalModules.ts
67
67
  function createConditionalApplyState(environment) {
@@ -1191,11 +1191,11 @@ async function runSignalModules(parameters) {
1191
1191
 
1192
1192
  // src/recipe.ts
1193
1193
  var INTERRUPTED_BEFORE_APPLY = /* @__PURE__ */ Symbol("recipe-interrupted-before-apply");
1194
- function isRecipeModuleLike(module) {
1195
- return module._isRecipe === true;
1194
+ function isRecipe(module) {
1195
+ return module.kind === "recipe";
1196
1196
  }
1197
1197
  function printRecipeChildResult(module, result) {
1198
- if (isRecipeModuleLike(module)) {
1198
+ if (isRecipe(module)) {
1199
1199
  printRecipeModuleResult(module.name, result.status, result.detail);
1200
1200
  return;
1201
1201
  }
@@ -1262,7 +1262,7 @@ async function checkRecipeChild(targetModule, connection, currentEnvironment) {
1262
1262
  return targetModule.check(connection, currentEnvironment);
1263
1263
  }
1264
1264
  async function applyRecipeChild(parameters) {
1265
- if (isRecipeModuleLike(parameters.targetModule)) {
1265
+ if (isRecipe(parameters.targetModule)) {
1266
1266
  return parameters.targetModule.apply(parameters.connection, parameters.currentEnvironment, {
1267
1267
  onChildStep: parameters.onChildStep,
1268
1268
  onSignalStep: parameters.onSignalStep,
@@ -1469,7 +1469,6 @@ function createRecipeDryRunApply(name, modules, needsDryRunApply) {
1469
1469
  const result = await dryRunRecipeModule({
1470
1470
  environment,
1471
1471
  recipeModule: {
1472
- _isRecipe: true,
1473
1472
  _modules: modules,
1474
1473
  async apply() {
1475
1474
  await Promise.resolve();
@@ -1479,6 +1478,7 @@ function createRecipeDryRunApply(name, modules, needsDryRunApply) {
1479
1478
  await Promise.resolve();
1480
1479
  return "ok";
1481
1480
  },
1481
+ kind: "recipe",
1482
1482
  name
1483
1483
  },
1484
1484
  shutdownSignal: parameters?.shutdownSignal,
@@ -1498,7 +1498,6 @@ function recipe(name, modules, options) {
1498
1498
  ...modules.some((module) => module._dryRunBlocker === true) ? { _dryRunBlocker: true } : {},
1499
1499
  ...modules.some((module) => module._dryRunMetaProducer === true) ? { _dryRunMetaProducer: true } : {},
1500
1500
  ...applyDryRun == null ? {} : { _applyDryRun: applyDryRun },
1501
- _isRecipe: true,
1502
1501
  _modules: modules,
1503
1502
  _signals: options?.signals,
1504
1503
  _supportsChildStepHook: true,
@@ -1526,6 +1525,7 @@ function recipe(name, modules, options) {
1526
1525
  }
1527
1526
  return "ok";
1528
1527
  },
1528
+ kind: "recipe",
1529
1529
  name
1530
1530
  };
1531
1531
  }
@@ -1634,4 +1634,3 @@ export {
1634
1634
  user,
1635
1635
  when
1636
1636
  };
1637
- //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- export { V as UpgradeOptions, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from '../index-udpAybq3.js';
1
+ export { V as UpgradeOptions, o as apt, p as archive, q as command, r as compose, s as cron, t as download, u as file, v as git, w as group, x as hostname, y as mount, z as net, A as op, B as package, C as quadlet, D as releaseUpgrade, F as rsync, G as script, H as service, I as ssh, J as sshd, K as swap, L as sysctl, P as system, Q as systemd, R as timer, T as ufw, U as user } from '../index-DwKdTyHo.js';
@@ -27,7 +27,7 @@ import {
27
27
  timer,
28
28
  ufw,
29
29
  user
30
- } from "../chunk-M3ZQJNKM.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.8",
3
+ "version": "0.13.0",
4
4
  "description": "Idempotent VPS setup tool in TypeScript",
5
5
  "type": "module",
6
6
  "files": [
@@ -23,6 +23,7 @@
23
23
  "devDependencies": {
24
24
  "@types/node": "24.13.2",
25
25
  "@types/ssh2": "^1.15.4",
26
+ "@vitest/coverage-v8": "4.1.9",
26
27
  "tsup": "^8.4.0",
27
28
  "typescript": "^6.0.3",
28
29
  "vitest": "^4.1.7"
@@ -56,9 +57,10 @@
56
57
  "scripts": {
57
58
  "build": "tsup",
58
59
  "test": "pnpm test:unit && pnpm test:dist",
60
+ "test:coverage": "vitest run --coverage",
59
61
  "test:dist": "pnpm build && vitest run --config vitest.distribution.config.ts",
60
62
  "test:integration": "pnpm build && vitest run --config vitest.integration.config.ts",
61
- "test:unit": "vitest run",
63
+ "test:unit": "vitest run --coverage",
62
64
  "test:watch": "vitest"
63
65
  }
64
66
  }