paratix 0.4.0 → 0.5.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.
@@ -9,7 +9,7 @@ import {
9
9
  shellQuote,
10
10
  sshdPortMeta,
11
11
  validateMode
12
- } from "./chunk-G3BMCQKU.js";
12
+ } from "./chunk-MHPFGCEY.js";
13
13
 
14
14
  // src/moduleFailure.ts
15
15
  function firstNonEmptyLine(text) {
@@ -676,11 +676,111 @@ function parseContainerStates(stdout) {
676
676
  function sanitizeUnitValue(value) {
677
677
  return value.replaceAll(new RegExp("[\\n\\r]", "gv"), "");
678
678
  }
679
- function generateSystemdUnit(projectDirectory, name, runtime) {
679
+ function validateGeneratedSystemdUnitContent(content, unitFileName) {
680
+ if (content.trim() === "") {
681
+ return failed(`[compose.systemd] generated empty unit content for ${unitFileName}`);
682
+ }
683
+ if (!content.includes("[Unit]") || !content.includes("[Service]")) {
684
+ return failed(`[compose.systemd] generated invalid unit content for ${unitFileName}`);
685
+ }
686
+ return null;
687
+ }
688
+ async function verifyNonEmptySystemdUnit(parameters) {
689
+ const writtenContent = await parameters.connection.readFile(parameters.filePath);
690
+ if (writtenContent.trim() === "") return "empty";
691
+ return writtenContent.trim() === parameters.content.trim() ? "matches" : "unexpected";
692
+ }
693
+ async function cleanupComposeSystemdTarget(parameters) {
694
+ await parameters.connection.exec(`rm -f ${shellQuote(parameters.filePath)}`, {
695
+ ignoreExitCode: true,
696
+ silent: true
697
+ });
698
+ }
699
+ async function rewriteComposeSystemdUnitViaShell(parameters) {
700
+ const encodedContent = Buffer.from(parameters.content, "utf8").toString("base64");
701
+ await cleanupComposeSystemdTarget(parameters);
702
+ const result = await parameters.connection.exec(
703
+ `printf '%s' ${shellQuote(encodedContent)} | base64 -d > ${shellQuote(parameters.filePath)} && chmod ${shellQuote(SYSTEMD_UNIT_MODE)} ${shellQuote(parameters.filePath)} && chown ${shellQuote("root:root")} ${shellQuote(parameters.filePath)}`,
704
+ EXEC_OPTS2
705
+ );
706
+ if (result.code !== 0) {
707
+ return failedCommand(
708
+ `[compose.systemd] shell fallback write failed for ${parameters.unitFileName}`,
709
+ result
710
+ );
711
+ }
712
+ const fallbackVerification = await verifyNonEmptySystemdUnit(parameters);
713
+ if (fallbackVerification === "matches") return null;
714
+ await cleanupComposeSystemdTarget(parameters);
715
+ if (fallbackVerification === "empty") {
716
+ return failed(
717
+ `[compose.systemd] wrote empty unit file for ${parameters.unitFileName} even after shell fallback`
718
+ );
719
+ }
720
+ return failed(
721
+ `[compose.systemd] wrote unexpected unit content for ${parameters.unitFileName} even after shell fallback`
722
+ );
723
+ }
724
+ async function applyComposeSystemdUnit(parameters) {
725
+ await prepareComposeSystemdTarget(parameters);
726
+ try {
727
+ await parameters.connection.writeFile(parameters.filePath, parameters.content, {
728
+ mode: SYSTEMD_UNIT_MODE
729
+ });
730
+ } catch (error) {
731
+ const reason = error instanceof Error ? error.message : String(error);
732
+ return failed(`[compose.systemd] atomic write failed for ${parameters.unitFileName}: ${reason}`);
733
+ }
734
+ const writeVerification = await verifyNonEmptySystemdUnit(parameters);
735
+ if (writeVerification !== "matches") {
736
+ const fallbackFailure = await rewriteComposeSystemdUnitViaShell(parameters);
737
+ if (fallbackFailure != null) return fallbackFailure;
738
+ }
739
+ const result = await parameters.connection.exec("systemctl daemon-reload", EXEC_OPTS2);
740
+ return result.code === 0 ? { status: "changed" } : failedCommand(`[compose.systemd] daemon-reload failed for ${parameters.unitFileName}`, result);
741
+ }
742
+ async function checkComposeSystemdUnit(parameters) {
743
+ const runtime = await getRuntime(parameters.ssh, parameters.explicitRuntime);
744
+ if (!runtime) return NEEDS_APPLY;
745
+ const exists = await parameters.ssh.exists(parameters.filePath);
746
+ if (!exists) return NEEDS_APPLY;
747
+ const content = generateSystemdUnit(parameters.projectDirectory, parameters.serviceName, {
748
+ detached: parameters.detached,
749
+ runtime
750
+ });
751
+ const remoteContent = await parameters.ssh.readFile(parameters.filePath);
752
+ return remoteContent.trim() === content.trim() ? "ok" : NEEDS_APPLY;
753
+ }
754
+ function resolveComposeSystemdIdentity(options) {
755
+ const serviceName = options.name ?? `compose-${basename(options.projectDirectory)}`;
756
+ if (!UNIT_NAME_PATTERN.test(serviceName)) {
757
+ throw new Error(
758
+ `compose.systemd: name must match ${String(UNIT_NAME_PATTERN)}, got: ${serviceName}`
759
+ );
760
+ }
761
+ const unitFileName = `${serviceName}.service`;
762
+ return {
763
+ filePath: `/etc/systemd/system/${unitFileName}`,
764
+ serviceName,
765
+ unitFileName
766
+ };
767
+ }
768
+ async function prepareComposeSystemdTarget(parameters) {
769
+ await parameters.connection.exec(`systemctl unmask ${shellQuote(parameters.unitFileName)}`, {
770
+ ignoreExitCode: true,
771
+ silent: true
772
+ });
773
+ await parameters.connection.exec(`rm -f ${shellQuote(parameters.filePath)}`, {
774
+ ignoreExitCode: true,
775
+ silent: true
776
+ });
777
+ }
778
+ function generateSystemdUnit(projectDirectory, name, options) {
680
779
  const safeName = sanitizeUnitValue(name);
681
780
  const safeDirectory = sanitizeUnitValue(projectDirectory);
682
- const lines = ["[Unit]", `Description=Compose stack: ${safeName}`];
683
- if (runtime === "docker") {
781
+ const composeUpCommand = options.detached ? `/usr/bin/env ${options.runtime} compose up -d --remove-orphans` : `/usr/bin/env ${options.runtime} compose up --remove-orphans`;
782
+ const lines = ["[Unit]", `Description=Compose stack: ${safeName}`, "Wants=network-online.target"];
783
+ if (options.runtime === "docker") {
684
784
  lines.push("After=network-online.target docker.service");
685
785
  lines.push("Requires=docker.service");
686
786
  } else {
@@ -692,8 +792,11 @@ function generateSystemdUnit(projectDirectory, name, runtime) {
692
792
  "Type=oneshot",
693
793
  "RemainAfterExit=yes",
694
794
  `WorkingDirectory=${safeDirectory}`,
695
- `ExecStart=/usr/bin/env ${runtime} compose up -d`,
696
- `ExecStop=/usr/bin/env ${runtime} compose down`,
795
+ `ExecStart=${composeUpCommand}`,
796
+ `ExecStop=/usr/bin/env ${options.runtime} compose down`,
797
+ "TimeoutStartSec=0",
798
+ "StandardOutput=journal",
799
+ "StandardError=journal",
697
800
  "",
698
801
  "[Install]",
699
802
  "WantedBy=multi-user.target",
@@ -888,6 +991,7 @@ var compose = {
888
991
  * explicitly provided.
889
992
  *
890
993
  * @param options - Configuration for the systemd unit.
994
+ * @param options.detached - When true, use `compose up -d`; otherwise start attached.
891
995
  * @param options.projectDirectory - The project directory on the remote host.
892
996
  * @param options.name - Optional service name (without `.service` suffix).
893
997
  * @param options.runtime - Explicit container runtime override.
@@ -895,14 +999,11 @@ var compose = {
895
999
  */
896
1000
  systemd(options) {
897
1001
  const { projectDirectory, runtime: explicitRuntime } = options;
898
- const serviceName = options.name ?? `compose-${basename(projectDirectory)}`;
899
- if (!UNIT_NAME_PATTERN.test(serviceName)) {
900
- throw new Error(
901
- `compose.systemd: name must match ${String(UNIT_NAME_PATTERN)}, got: ${serviceName}`
902
- );
903
- }
904
- const unitFileName = `${serviceName}.service`;
905
- const filePath = `/etc/systemd/system/${unitFileName}`;
1002
+ const detached = options.detached ?? false;
1003
+ const { filePath, serviceName, unitFileName } = resolveComposeSystemdIdentity({
1004
+ name: options.name,
1005
+ projectDirectory
1006
+ });
906
1007
  return {
907
1008
  async apply(ssh2) {
908
1009
  const connection = requireComposeSsh(ssh2, "systemd", projectDirectory);
@@ -914,20 +1015,26 @@ var compose = {
914
1015
  ssh: connection
915
1016
  });
916
1017
  if (typeof runtime !== "string") return runtime;
917
- const content = generateSystemdUnit(projectDirectory, serviceName, runtime);
918
- await connection.writeFile(filePath, content, { mode: SYSTEMD_UNIT_MODE });
919
- const result = await connection.exec("systemctl daemon-reload", EXEC_OPTS2);
920
- return result.code === 0 ? { status: "changed" } : failedCommand(`[compose.systemd] daemon-reload failed for ${unitFileName}`, result);
1018
+ const content = generateSystemdUnit(projectDirectory, serviceName, { detached, runtime });
1019
+ const validationFailure = validateGeneratedSystemdUnitContent(content, unitFileName);
1020
+ if (validationFailure != null) return validationFailure;
1021
+ return applyComposeSystemdUnit({
1022
+ connection,
1023
+ content,
1024
+ filePath,
1025
+ unitFileName
1026
+ });
921
1027
  },
922
1028
  async check(ssh2) {
923
1029
  if (!ssh2) return NEEDS_APPLY;
924
- const rt = await getRuntime(ssh2, explicitRuntime);
925
- if (!rt) return NEEDS_APPLY;
926
- const exists = await ssh2.exists(filePath);
927
- if (!exists) return NEEDS_APPLY;
928
- const content = generateSystemdUnit(projectDirectory, serviceName, rt);
929
- const remoteContent = await ssh2.readFile(filePath);
930
- return remoteContent.trim() === content.trim() ? "ok" : NEEDS_APPLY;
1030
+ return checkComposeSystemdUnit({
1031
+ detached,
1032
+ explicitRuntime,
1033
+ filePath,
1034
+ projectDirectory,
1035
+ serviceName,
1036
+ ssh: ssh2
1037
+ });
931
1038
  },
932
1039
  name: `compose.systemd: ${unitFileName}`
933
1040
  };
@@ -1755,7 +1862,7 @@ function stat(remotePath) {
1755
1862
  };
1756
1863
  }
1757
1864
 
1758
- // src/modules/file.ts
1865
+ // src/modules/fileMetadataHelpers.ts
1759
1866
  var DEFAULT_FILE_WRITE_MODE2 = "0644";
1760
1867
  async function readOwnership(ssh2, remotePath) {
1761
1868
  const raw = await ssh2.output(`stat -c '%a %U %G' ${shellQuote(remotePath)}`);
@@ -1794,9 +1901,39 @@ function ownershipMatches(current, options) {
1794
1901
  if (expectsGroup && current.group !== expectedGroup) return false;
1795
1902
  return true;
1796
1903
  }
1904
+ function createMetadataModule(kind, remotePath, value) {
1905
+ const name = `file.${kind}: ${remotePath}`;
1906
+ return {
1907
+ async apply(ssh2) {
1908
+ if (!ssh2) return failed(`[${name}] SSH connection is required`);
1909
+ if (kind === "chmod") validateMode(value);
1910
+ await ssh2.exec(`${kind} ${shellQuote(value)} ${shellQuote(remotePath)}`, { silent: true });
1911
+ return { status: "changed" };
1912
+ },
1913
+ async check(ssh2) {
1914
+ if (!ssh2) return NEEDS_APPLY;
1915
+ if (!await ssh2.exists(remotePath)) return NEEDS_APPLY;
1916
+ const ownership = await readOwnership(ssh2, remotePath);
1917
+ const matches = kind === "chmod" ? { mode: value } : { owner: value };
1918
+ return ownershipMatches(ownership, matches) ? "ok" : NEEDS_APPLY;
1919
+ },
1920
+ name
1921
+ };
1922
+ }
1923
+
1924
+ // src/modules/file.ts
1797
1925
  function splitLines(content) {
1798
1926
  return content.split(new RegExp("\\r?\\n", "v"));
1799
1927
  }
1928
+ function findFirstMatchingLineIndex(lines, pattern) {
1929
+ return lines.findIndex((candidateLine) => pattern.test(candidateLine));
1930
+ }
1931
+ function splitLinesPreservingTrailingNewline(content) {
1932
+ const hasTrailingNewline = new RegExp("\\r?\\n$", "v").test(content);
1933
+ const lines = splitLines(content);
1934
+ if (hasTrailingNewline && lines.at(-1) === "") lines.pop();
1935
+ return { hasTrailingNewline, lines };
1936
+ }
1800
1937
  function validateAbsentPath(remotePath) {
1801
1938
  const trimmedPath = remotePath.trim();
1802
1939
  if (trimmedPath.length === 0) {
@@ -1836,6 +1973,12 @@ var file = {
1836
1973
  },
1837
1974
  assemble,
1838
1975
  block,
1976
+ chmod(remotePath, mode) {
1977
+ return createMetadataModule("chmod", remotePath, mode);
1978
+ },
1979
+ chown(remotePath, owner) {
1980
+ return createMetadataModule("chown", remotePath, owner);
1981
+ },
1839
1982
  /**
1840
1983
  * Upload a local file to the remote host.
1841
1984
  * The file is only transferred when the remote SHA-256 differs from the local one.
@@ -1936,13 +2079,17 @@ var file = {
1936
2079
  });
1937
2080
  } else {
1938
2081
  const content = await ssh2.readFile(remotePath);
2082
+ const { hasTrailingNewline, lines } = splitLinesPreservingTrailingNewline(content);
1939
2083
  const pattern = new RegExp(options.match, "mu");
1940
- if (!pattern.test(content)) {
2084
+ const matchingLineIndex = findFirstMatchingLineIndex(lines, pattern);
2085
+ if (matchingLineIndex === -1) {
1941
2086
  return failed(
1942
2087
  `[file.line: ${remotePath}] No line matching ${options.match} found for replacement`
1943
2088
  );
1944
2089
  }
1945
- const newContent = content.replace(pattern, line);
2090
+ lines[matchingLineIndex] = line;
2091
+ let newContent = lines.join("\n");
2092
+ if (hasTrailingNewline) newContent += "\n";
1946
2093
  const ownership = await readOwnership(ssh2, remotePath);
1947
2094
  await guardedWriteFile(ssh2, {
1948
2095
  mode: normalizeMode2(ownership.mode),
@@ -1961,7 +2108,8 @@ var file = {
1961
2108
  const lines = splitLines(content);
1962
2109
  if (options?.match != null) {
1963
2110
  const matchPattern = new RegExp(options.match, "mu");
1964
- const matchedLine = lines.find((candidateLine) => matchPattern.test(candidateLine));
2111
+ const matchedLineIndex = findFirstMatchingLineIndex(lines, matchPattern);
2112
+ const matchedLine = matchedLineIndex === -1 ? void 0 : lines[matchedLineIndex];
1965
2113
  return matchedLine === line ? "ok" : NEEDS_APPLY;
1966
2114
  }
1967
2115
  return lines.includes(line) ? "ok" : NEEDS_APPLY;
@@ -3212,6 +3360,144 @@ var pkg = {
3212
3360
  }
3213
3361
  };
3214
3362
 
3363
+ // src/modules/quadlet.ts
3364
+ var CONTAINERS_SYSTEMD_DIRECTORY = "/etc/containers/systemd";
3365
+ var QUADLET_FILE_MODE = "0644";
3366
+ var SYSTEMCTL = "systemctl";
3367
+ var UNIT_NAME_PATTERN2 = new RegExp("^[\\w@.\\-]+$", "v");
3368
+ function sanitizeQuadletValue(value) {
3369
+ return value.replaceAll(new RegExp("[\\n\\r]", "gv"), "");
3370
+ }
3371
+ function renderQuadletLine(key, value) {
3372
+ return `${key}=${sanitizeQuadletValue(value)}`;
3373
+ }
3374
+ function renderQuadletSection(name, lines) {
3375
+ return [`[${name}]`, ...lines, ""].join("\n");
3376
+ }
3377
+ function validateQuadletName(name) {
3378
+ if (!UNIT_NAME_PATTERN2.test(name)) {
3379
+ throw new Error(`quadlet.container: name must match ${String(UNIT_NAME_PATTERN2)}, got: ${name}`);
3380
+ }
3381
+ }
3382
+ function renderQuadletEnvironment(environment) {
3383
+ return Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => renderQuadletLine("Environment", `${key}=${value}`));
3384
+ }
3385
+ function renderQuadletRepeated(key, values) {
3386
+ return values.map((value) => renderQuadletLine(key, value));
3387
+ }
3388
+ function maybeRenderQuadletLine(key, value, transform) {
3389
+ if (value == null || value === "") return null;
3390
+ return renderQuadletLine(key, transform == null ? value : transform(value));
3391
+ }
3392
+ function compactQuadletLines(lines) {
3393
+ return lines.filter((line) => line != null);
3394
+ }
3395
+ function buildQuadletUnitSection(options) {
3396
+ return renderQuadletSection("Unit", [
3397
+ renderQuadletLine("Description", options.description ?? `Podman container: ${options.name}`),
3398
+ "Wants=network-online.target",
3399
+ "After=network-online.target"
3400
+ ]);
3401
+ }
3402
+ function buildQuadletContainerLines(options) {
3403
+ return compactQuadletLines([
3404
+ renderQuadletLine("Image", options.image),
3405
+ maybeRenderQuadletLine("ContainerName", options.containerName),
3406
+ maybeRenderQuadletLine("AutoUpdate", options.autoUpdate),
3407
+ maybeRenderQuadletLine("Exec", options.exec?.join(" ")),
3408
+ maybeRenderQuadletLine("Restart", options.restart),
3409
+ ...renderQuadletRepeated("Network", options.networks ?? []),
3410
+ ...renderQuadletRepeated("PodmanArgs", options.podmanArgs ?? []),
3411
+ ...renderQuadletRepeated("PublishPort", options.publishPorts ?? []),
3412
+ ...renderQuadletRepeated("Volume", options.volumes ?? []),
3413
+ ...renderQuadletEnvironment(options.environment ?? {})
3414
+ ]);
3415
+ }
3416
+ function buildQuadletInstallSection(options) {
3417
+ return renderQuadletSection("Install", [
3418
+ renderQuadletLine("WantedBy", options.wantedBy ?? "multi-user.target")
3419
+ ]);
3420
+ }
3421
+ function generateContainerQuadlet(options) {
3422
+ return [
3423
+ buildQuadletUnitSection(options),
3424
+ renderQuadletSection("Container", buildQuadletContainerLines(options)),
3425
+ buildQuadletInstallSection(options)
3426
+ ].join("\n").trimEnd();
3427
+ }
3428
+ async function createQuadletDirectory(ssh2) {
3429
+ return ssh2.exec(`mkdir -p ${shellQuote(CONTAINERS_SYSTEMD_DIRECTORY)}`, {
3430
+ ignoreExitCode: true,
3431
+ silent: true
3432
+ });
3433
+ }
3434
+ async function applyQuadletFile(parameters) {
3435
+ const mkdirResult = await createQuadletDirectory(parameters.ssh);
3436
+ if (mkdirResult.code !== 0) {
3437
+ return failedCommand(
3438
+ `[quadlet.container: ${parameters.name}] failed to create quadlet directory`,
3439
+ mkdirResult
3440
+ );
3441
+ }
3442
+ await parameters.ssh.writeFile(parameters.filePath, parameters.content, {
3443
+ mode: QUADLET_FILE_MODE
3444
+ });
3445
+ const daemonReload = await parameters.ssh.exec(`${SYSTEMCTL} daemon-reload`, {
3446
+ ignoreExitCode: true,
3447
+ silent: true
3448
+ });
3449
+ return daemonReload.code === 0 ? { status: "changed" } : failedCommand(
3450
+ `[quadlet.container: ${parameters.name}] systemctl daemon-reload failed`,
3451
+ daemonReload
3452
+ );
3453
+ }
3454
+ async function checkQuadletFile(parameters) {
3455
+ const exists = await parameters.ssh.exists(parameters.filePath);
3456
+ if (!exists) return NEEDS_APPLY;
3457
+ const remoteContent = await parameters.ssh.readFile(parameters.filePath);
3458
+ return remoteContent.trim() === parameters.content.trim() ? "ok" : NEEDS_APPLY;
3459
+ }
3460
+ var quadlet = {
3461
+ /**
3462
+ * Write a Podman Quadlet `.container` definition and reload systemd when it changes.
3463
+ *
3464
+ * The resulting generated service can be controlled with `service.enabled(name)`
3465
+ * and `service.running(name)`.
3466
+ *
3467
+ * @param options - Configuration for the Quadlet container definition.
3468
+ * @param options.name - Quadlet base name without `.container`.
3469
+ * @param options.image - Container image reference.
3470
+ * @param options.description - Optional systemd unit description.
3471
+ * @param options.containerName - Optional explicit Podman container name.
3472
+ * @param options.autoUpdate - Optional Podman auto-update policy.
3473
+ * @param options.environment - Optional environment variables.
3474
+ * @param options.exec - Optional command and arguments for `Exec=`.
3475
+ * @param options.networks - Optional `Network=` entries.
3476
+ * @param options.podmanArgs - Optional `PodmanArgs=` entries.
3477
+ * @param options.publishPorts - Optional `PublishPort=` entries.
3478
+ * @param options.restart - Optional `Restart=` policy in the `[Container]` section.
3479
+ * @param options.volumes - Optional `Volume=` entries.
3480
+ * @param options.wantedBy - Optional install target. Defaults to `multi-user.target`.
3481
+ * @returns A Module that ensures the Quadlet file is present and up to date.
3482
+ */
3483
+ container(options) {
3484
+ validateQuadletName(options.name);
3485
+ const filePath = `${CONTAINERS_SYSTEMD_DIRECTORY}/${options.name}.container`;
3486
+ const content = generateContainerQuadlet(options);
3487
+ return {
3488
+ async apply(ssh2) {
3489
+ if (!ssh2) return failed(`[quadlet.container: ${options.name}] SSH connection is required`);
3490
+ return applyQuadletFile({ content, filePath, name: options.name, ssh: ssh2 });
3491
+ },
3492
+ async check(ssh2) {
3493
+ if (!ssh2) return NEEDS_APPLY;
3494
+ return checkQuadletFile({ content, filePath, ssh: ssh2 });
3495
+ },
3496
+ name: `quadlet.container: ${options.name}`
3497
+ };
3498
+ }
3499
+ };
3500
+
3215
3501
  // src/modules/releaseUpgrade.ts
3216
3502
  var NONINTERACTIVE2 = "DEBIAN_FRONTEND=noninteractive";
3217
3503
  var CODENAME_RE = new RegExp("^[a-z]{3,20}$", "v");
@@ -3666,7 +3952,7 @@ var script = {
3666
3952
  };
3667
3953
 
3668
3954
  // src/modules/service.ts
3669
- var SYSTEMCTL = "systemctl";
3955
+ var SYSTEMCTL2 = "systemctl";
3670
3956
  var service = {
3671
3957
  /**
3672
3958
  * Ensure a systemd service is disabled and will not start on boot.
@@ -3677,7 +3963,7 @@ var service = {
3677
3963
  return {
3678
3964
  async apply(ssh2) {
3679
3965
  if (!ssh2) return failed(`[service.disabled: ${name}] SSH connection is required`);
3680
- const result = await ssh2.exec(`${SYSTEMCTL} disable ${shellQuote(name)}`, {
3966
+ const result = await ssh2.exec(`${SYSTEMCTL2} disable ${shellQuote(name)}`, {
3681
3967
  ignoreExitCode: true,
3682
3968
  silent: true
3683
3969
  });
@@ -3685,7 +3971,7 @@ var service = {
3685
3971
  },
3686
3972
  async check(ssh2) {
3687
3973
  if (!ssh2) return NEEDS_APPLY;
3688
- const enabled = await ssh2.test(`${SYSTEMCTL} is-enabled --quiet ${shellQuote(name)}`);
3974
+ const enabled = await ssh2.test(`${SYSTEMCTL2} is-enabled --quiet ${shellQuote(name)}`);
3689
3975
  return enabled ? "needs-apply" : "ok";
3690
3976
  },
3691
3977
  name: `service.disabled: ${name}`
@@ -3700,7 +3986,7 @@ var service = {
3700
3986
  return {
3701
3987
  async apply(ssh2) {
3702
3988
  if (!ssh2) return failed(`[service.enabled: ${name}] SSH connection is required`);
3703
- const result = await ssh2.exec(`${SYSTEMCTL} enable ${shellQuote(name)}`, {
3989
+ const result = await ssh2.exec(`${SYSTEMCTL2} enable ${shellQuote(name)}`, {
3704
3990
  ignoreExitCode: true,
3705
3991
  silent: true
3706
3992
  });
@@ -3708,7 +3994,7 @@ var service = {
3708
3994
  },
3709
3995
  async check(ssh2) {
3710
3996
  if (!ssh2) return NEEDS_APPLY;
3711
- return await ssh2.test(`${SYSTEMCTL} is-enabled --quiet ${shellQuote(name)}`) ? "ok" : NEEDS_APPLY;
3997
+ return await ssh2.test(`${SYSTEMCTL2} is-enabled --quiet ${shellQuote(name)}`) ? "ok" : NEEDS_APPLY;
3712
3998
  },
3713
3999
  name: `service.enabled: ${name}`
3714
4000
  };
@@ -3724,7 +4010,7 @@ var service = {
3724
4010
  async apply(ssh2) {
3725
4011
  if (!ssh2) return failed("[service.facts] SSH connection is required");
3726
4012
  const result = await ssh2.exec(
3727
- `${SYSTEMCTL} list-units --type=service --all --no-pager --no-legend`,
4013
+ `${SYSTEMCTL2} list-units --type=service --all --no-pager --no-legend`,
3728
4014
  { ignoreExitCode: true, silent: true }
3729
4015
  );
3730
4016
  if (result.code !== 0) {
@@ -3759,7 +4045,7 @@ var service = {
3759
4045
  return {
3760
4046
  async apply(ssh2) {
3761
4047
  if (!ssh2) return failed(`[service.reload: ${name}] SSH connection is required`);
3762
- const result = await ssh2.exec(`${SYSTEMCTL} reload ${shellQuote(name)}`, {
4048
+ const result = await ssh2.exec(`${SYSTEMCTL2} reload ${shellQuote(name)}`, {
3763
4049
  ignoreExitCode: true,
3764
4050
  silent: true
3765
4051
  });
@@ -3781,7 +4067,7 @@ var service = {
3781
4067
  return {
3782
4068
  async apply(ssh2) {
3783
4069
  if (!ssh2) return failed(`[service.restart: ${name}] SSH connection is required`);
3784
- const result = await ssh2.exec(`${SYSTEMCTL} restart ${shellQuote(name)}`, {
4070
+ const result = await ssh2.exec(`${SYSTEMCTL2} restart ${shellQuote(name)}`, {
3785
4071
  ignoreExitCode: true,
3786
4072
  silent: true
3787
4073
  });
@@ -3803,7 +4089,7 @@ var service = {
3803
4089
  return {
3804
4090
  async apply(ssh2) {
3805
4091
  if (!ssh2) return failed(`[service.running: ${name}] SSH connection is required`);
3806
- const result = await ssh2.exec(`${SYSTEMCTL} start ${shellQuote(name)}`, {
4092
+ const result = await ssh2.exec(`${SYSTEMCTL2} start ${shellQuote(name)}`, {
3807
4093
  ignoreExitCode: true,
3808
4094
  silent: true
3809
4095
  });
@@ -3811,7 +4097,7 @@ var service = {
3811
4097
  },
3812
4098
  async check(ssh2) {
3813
4099
  if (!ssh2) return NEEDS_APPLY;
3814
- return await ssh2.test(`${SYSTEMCTL} is-active --quiet ${shellQuote(name)}`) ? "ok" : NEEDS_APPLY;
4100
+ return await ssh2.test(`${SYSTEMCTL2} is-active --quiet ${shellQuote(name)}`) ? "ok" : NEEDS_APPLY;
3815
4101
  },
3816
4102
  name: `service.running: ${name}`
3817
4103
  };
@@ -3825,7 +4111,7 @@ var service = {
3825
4111
  return {
3826
4112
  async apply(ssh2) {
3827
4113
  if (!ssh2) return failed(`[service.stopped: ${name}] SSH connection is required`);
3828
- const result = await ssh2.exec(`${SYSTEMCTL} stop ${shellQuote(name)}`, {
4114
+ const result = await ssh2.exec(`${SYSTEMCTL2} stop ${shellQuote(name)}`, {
3829
4115
  ignoreExitCode: true,
3830
4116
  silent: true
3831
4117
  });
@@ -3833,7 +4119,7 @@ var service = {
3833
4119
  },
3834
4120
  async check(ssh2) {
3835
4121
  if (!ssh2) return NEEDS_APPLY;
3836
- const active = await ssh2.test(`${SYSTEMCTL} is-active --quiet ${shellQuote(name)}`);
4122
+ const active = await ssh2.test(`${SYSTEMCTL2} is-active --quiet ${shellQuote(name)}`);
3837
4123
  return active ? "needs-apply" : "ok";
3838
4124
  },
3839
4125
  name: `service.stopped: ${name}`
@@ -4124,7 +4410,7 @@ var DEFAULT_SSH_PORT3 = 22;
4124
4410
  var PRIVILEGE_SEPARATION_DIRECTORY = "/run/sshd";
4125
4411
  var SSHD_CONFIG_PATH = "/etc/ssh/sshd_config";
4126
4412
  var SSHD_CONFIG_MODE = "0644";
4127
- var SYSTEMCTL2 = "systemctl";
4413
+ var SYSTEMCTL3 = "systemctl";
4128
4414
  var REGEXP_SPECIAL = /* @__PURE__ */ new Set(["?", ".", "(", ")", "[", "]", "{", "}", "*", "\\", "^", "+", "|", "$"]);
4129
4415
  function escapeRegExp(s) {
4130
4416
  let result = "";
@@ -4162,14 +4448,14 @@ async function disableSocketActivatedSsh(ssh2) {
4162
4448
  });
4163
4449
  }
4164
4450
  async function resolveSshServiceUnit(ssh2) {
4165
- const sshdExists = await ssh2.exec(`${SYSTEMCTL2} cat sshd.service >/dev/null 2>&1`, {
4451
+ const sshdExists = await ssh2.exec(`${SYSTEMCTL3} cat sshd.service >/dev/null 2>&1`, {
4166
4452
  ignoreExitCode: true,
4167
4453
  silent: true
4168
4454
  });
4169
4455
  if (sshdExists.code === 0) {
4170
4456
  return "sshd";
4171
4457
  }
4172
- const sshExists = await ssh2.exec(`${SYSTEMCTL2} cat ssh.service >/dev/null 2>&1`, {
4458
+ const sshExists = await ssh2.exec(`${SYSTEMCTL3} cat ssh.service >/dev/null 2>&1`, {
4173
4459
  ignoreExitCode: true,
4174
4460
  silent: true
4175
4461
  });
@@ -4182,7 +4468,7 @@ async function resolveSshServiceUnit(ssh2) {
4182
4468
  }
4183
4469
  async function reloadSshd(ssh2) {
4184
4470
  const serviceUnit = await resolveSshServiceUnit(ssh2);
4185
- const result = await ssh2.exec(`${SYSTEMCTL2} reload ${serviceUnit}`, {
4471
+ const result = await ssh2.exec(`${SYSTEMCTL3} reload ${serviceUnit}`, {
4186
4472
  ignoreExitCode: true,
4187
4473
  silent: true
4188
4474
  });
@@ -4263,7 +4549,7 @@ async function applySshdPort(ssh2, targetPort) {
4263
4549
  try {
4264
4550
  await disableSocketActivatedSsh(ssh2);
4265
4551
  const serviceUnit = await resolveSshServiceUnit(ssh2);
4266
- await ssh2.exec(`${SYSTEMCTL2} restart ${serviceUnit}`, { silent: true });
4552
+ await ssh2.exec(`${SYSTEMCTL3} restart ${serviceUnit}`, { silent: true });
4267
4553
  } catch (error) {
4268
4554
  if (!isRestartDisconnect(error)) {
4269
4555
  ssh2.removePort(targetPort);
@@ -4374,8 +4660,259 @@ var sshd = {
4374
4660
  }
4375
4661
  };
4376
4662
 
4377
- // src/modules/sysctl.ts
4663
+ // src/modules/swapFileHelpers.ts
4664
+ import { dirname } from "path";
4378
4665
  var EXEC_OPTS8 = { ignoreExitCode: true, silent: true };
4666
+ var FSTAB_PATH2 = "/etc/fstab";
4667
+ var FSTAB_MODE2 = "0644";
4668
+ var KIBI = 1024;
4669
+ var POWER_0 = 0;
4670
+ var POWER_1 = 1;
4671
+ var POWER_2 = 2;
4672
+ var POWER_3 = 3;
4673
+ var POWER_4 = 4;
4674
+ var POWER_5 = 5;
4675
+ var SIZE_POWERS = {
4676
+ "": POWER_0,
4677
+ G: POWER_3,
4678
+ K: POWER_1,
4679
+ M: POWER_2,
4680
+ P: POWER_5,
4681
+ T: POWER_4
4682
+ };
4683
+ function isSizeUnit(unit) {
4684
+ return Object.hasOwn(SIZE_POWERS, unit);
4685
+ }
4686
+ function normalizeSizeToBytes(size) {
4687
+ if (typeof size === "number") {
4688
+ if (!Number.isInteger(size) || size <= 0) {
4689
+ throw new Error("swap.file: numeric size must be a positive integer byte count");
4690
+ }
4691
+ return size;
4692
+ }
4693
+ const trimmed = size.trim();
4694
+ const match = new RegExp("^(?<value>\\d+)(?<unit>[KMGTP]?)$", "iv").exec(trimmed);
4695
+ if (match?.groups == null) {
4696
+ throw new Error(`swap.file: unsupported size format "${size}"`);
4697
+ }
4698
+ const value = Number.parseInt(match.groups.value, 10);
4699
+ const unit = match.groups.unit.toUpperCase();
4700
+ if (!isSizeUnit(unit)) {
4701
+ throw new Error(`swap.file: unsupported size unit "${unit}"`);
4702
+ }
4703
+ return value * KIBI ** SIZE_POWERS[unit];
4704
+ }
4705
+ function normalizeSizeForCommand(size) {
4706
+ return typeof size === "number" ? String(size) : size.trim();
4707
+ }
4708
+ function buildSwapFstabLine(path, priority) {
4709
+ const options = priority == null ? "sw" : `sw,pri=${String(priority)}`;
4710
+ return `${path} none swap ${options} 0 0`;
4711
+ }
4712
+ function findFstabEntry2(fstabContent, path) {
4713
+ for (const line of fstabContent.split("\n")) {
4714
+ const trimmed = line.trim();
4715
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
4716
+ const fields = trimmed.split(new RegExp("\\s+", "v"));
4717
+ if (fields[0] === path) return trimmed;
4718
+ }
4719
+ return null;
4720
+ }
4721
+ function upsertFstabEntry2(fstabContent, path, newLine) {
4722
+ const lines = fstabContent.split("\n");
4723
+ const index = lines.findIndex((line) => {
4724
+ const trimmed = line.trim();
4725
+ if (trimmed === "" || trimmed.startsWith("#")) return false;
4726
+ const fields = trimmed.split(new RegExp("\\s+", "v"));
4727
+ return fields[0] === path;
4728
+ });
4729
+ if (index === -1) {
4730
+ while (lines.length > 0 && lines.at(-1)?.trim() === "") {
4731
+ lines.pop();
4732
+ }
4733
+ lines.push(newLine);
4734
+ } else {
4735
+ lines[index] = newLine;
4736
+ }
4737
+ return `${lines.join("\n")}
4738
+ `;
4739
+ }
4740
+ function removeFstabEntry2(fstabContent, path) {
4741
+ const lines = fstabContent.split("\n");
4742
+ const result = lines.filter((line) => {
4743
+ const trimmed = line.trim();
4744
+ if (trimmed === "" || trimmed.startsWith("#")) return true;
4745
+ const fields = trimmed.split(new RegExp("\\s+", "v"));
4746
+ return fields[0] !== path;
4747
+ });
4748
+ return `${result.join("\n")}
4749
+ `;
4750
+ }
4751
+ async function isSwapActive(ssh2, path) {
4752
+ const activeSwaps = await ssh2.lines("swapon --show=NAME --noheadings");
4753
+ return activeSwaps.some((line) => line.trim() === path);
4754
+ }
4755
+ async function hasSwapSignature(ssh2, path) {
4756
+ return ssh2.test(`swaplabel ${shellQuote(path)} >/dev/null 2>&1`);
4757
+ }
4758
+ async function readFileSizeInBytes(ssh2, path) {
4759
+ const output = await ssh2.output(`stat -c %s ${shellQuote(path)}`);
4760
+ return Number.parseInt(output.trim(), 10);
4761
+ }
4762
+ async function ensureSwapFstabState(parameters) {
4763
+ const fstabContent = await parameters.ssh.readFile(FSTAB_PATH2);
4764
+ const currentEntry = findFstabEntry2(fstabContent, parameters.path);
4765
+ if (parameters.desiredLine == null) {
4766
+ if (currentEntry == null) return false;
4767
+ const removedContent = removeFstabEntry2(fstabContent, parameters.path);
4768
+ await guardedWriteFile(parameters.ssh, {
4769
+ mode: FSTAB_MODE2,
4770
+ newContent: removedContent,
4771
+ originalContent: fstabContent,
4772
+ remotePath: FSTAB_PATH2
4773
+ });
4774
+ return true;
4775
+ }
4776
+ if (currentEntry === parameters.desiredLine) return false;
4777
+ const updatedContent = upsertFstabEntry2(fstabContent, parameters.path, parameters.desiredLine);
4778
+ await guardedWriteFile(parameters.ssh, {
4779
+ mode: FSTAB_MODE2,
4780
+ newContent: updatedContent,
4781
+ originalContent: fstabContent,
4782
+ remotePath: FSTAB_PATH2
4783
+ });
4784
+ return true;
4785
+ }
4786
+ async function ensureSwapFilePresent(parameters) {
4787
+ const createDirectoryResult = await parameters.ssh.exec(
4788
+ `mkdir -p ${shellQuote(dirname(parameters.path))}`,
4789
+ EXEC_OPTS8
4790
+ );
4791
+ if (createDirectoryResult.code !== 0) {
4792
+ return failedCommand(`[swap.file: ${parameters.path}] mkdir failed`, createDirectoryResult);
4793
+ }
4794
+ const createFileResult = await parameters.ssh.exec(
4795
+ `fallocate -l ${shellQuote(parameters.size)} ${shellQuote(parameters.path)} || dd if=/dev/zero of=${shellQuote(parameters.path)} bs=${shellQuote(parameters.size)} count=1 status=none`,
4796
+ EXEC_OPTS8
4797
+ );
4798
+ if (createFileResult.code !== 0) {
4799
+ return failedCommand(
4800
+ `[swap.file: ${parameters.path}] swap file creation failed`,
4801
+ createFileResult
4802
+ );
4803
+ }
4804
+ const chmodResult = await parameters.ssh.exec(
4805
+ `chmod ${shellQuote(parameters.mode)} ${shellQuote(parameters.path)}`,
4806
+ EXEC_OPTS8
4807
+ );
4808
+ if (chmodResult.code !== 0) {
4809
+ return failedCommand(`[swap.file: ${parameters.path}] chmod failed`, chmodResult);
4810
+ }
4811
+ const makeSwapResult = await parameters.ssh.exec(
4812
+ `mkswap ${shellQuote(parameters.path)}`,
4813
+ EXEC_OPTS8
4814
+ );
4815
+ return makeSwapResult.code === 0 ? true : failedCommand(`[swap.file: ${parameters.path}] mkswap failed`, makeSwapResult);
4816
+ }
4817
+ function normalizeSwapFileOptions(options) {
4818
+ const state = options.state ?? "present";
4819
+ return {
4820
+ expectedFstabLine: state === "present" ? buildSwapFstabLine(options.path, options.priority) : null,
4821
+ mode: options.mode ?? "0600",
4822
+ path: options.path,
4823
+ sizeBytes: normalizeSizeToBytes(options.size),
4824
+ sizeForCommand: normalizeSizeForCommand(options.size),
4825
+ state
4826
+ };
4827
+ }
4828
+ async function needsSwapRecreation(ssh2, options) {
4829
+ if (!await ssh2.exists(options.path)) return true;
4830
+ if (await readFileSizeInBytes(ssh2, options.path) !== options.sizeBytes) return true;
4831
+ return !await hasSwapSignature(ssh2, options.path);
4832
+ }
4833
+ async function hasSwapFstabEntry(ssh2, options) {
4834
+ const currentFstabContent = await ssh2.readFile(FSTAB_PATH2);
4835
+ return findFstabEntry2(currentFstabContent, options.path) === options.expectedFstabLine;
4836
+ }
4837
+ async function hasNoSwapFstabEntry(ssh2, path) {
4838
+ const currentFstabContent = await ssh2.readFile(FSTAB_PATH2);
4839
+ return findFstabEntry2(currentFstabContent, path) == null;
4840
+ }
4841
+
4842
+ // src/modules/swapHelpers.ts
4843
+ var EXEC_OPTS9 = { ignoreExitCode: true, silent: true };
4844
+ async function disableSwap(ssh2, path) {
4845
+ if (!await isSwapActive(ssh2, path)) return false;
4846
+ const result = await ssh2.exec(`swapoff ${shellQuote(path)}`, EXEC_OPTS9);
4847
+ return result.code === 0 ? true : failedCommand(`[swap.file: ${path}] swapoff failed`, result);
4848
+ }
4849
+ async function removeSwapFile(ssh2, path) {
4850
+ if (!await ssh2.exists(path)) return false;
4851
+ const result = await ssh2.exec(`rm -f ${shellQuote(path)}`, EXEC_OPTS9);
4852
+ return result.code === 0 ? true : failedCommand(`[swap.file: ${path}] rm failed`, result);
4853
+ }
4854
+ async function recreateSwapFile(ssh2, options) {
4855
+ if (!await needsSwapRecreation(ssh2, options)) return "ok";
4856
+ const disableResult = await disableSwap(ssh2, options.path);
4857
+ if (typeof disableResult !== "boolean") return disableResult;
4858
+ const removeResult = await removeSwapFile(ssh2, options.path);
4859
+ if (typeof removeResult !== "boolean") return removeResult;
4860
+ const createResult = await ensureSwapFilePresent({
4861
+ mode: options.mode,
4862
+ path: options.path,
4863
+ size: options.sizeForCommand,
4864
+ ssh: ssh2
4865
+ });
4866
+ return createResult === true ? "changed" : createResult;
4867
+ }
4868
+ async function enableSwap(ssh2, path) {
4869
+ if (await isSwapActive(ssh2, path)) return false;
4870
+ const result = await ssh2.exec(`swapon ${shellQuote(path)}`, EXEC_OPTS9);
4871
+ return result.code === 0 ? true : failedCommand(`[swap.file: ${path}] swapon failed`, result);
4872
+ }
4873
+ async function applyAbsentSwapFile(ssh2, options) {
4874
+ let swapChanged = false;
4875
+ const disableResult = await disableSwap(ssh2, options.path);
4876
+ if (typeof disableResult !== "boolean") return disableResult;
4877
+ if (disableResult) swapChanged = true;
4878
+ if (await ensureSwapFstabState({ desiredLine: null, path: options.path, ssh: ssh2 })) swapChanged = true;
4879
+ const removeResult = await removeSwapFile(ssh2, options.path);
4880
+ if (typeof removeResult !== "boolean") return removeResult;
4881
+ if (removeResult) swapChanged = true;
4882
+ return { status: swapChanged ? "changed" : "ok" };
4883
+ }
4884
+ async function applyPresentSwapFile(ssh2, options) {
4885
+ let swapChanged = false;
4886
+ const recreateResult = await recreateSwapFile(ssh2, options);
4887
+ if (typeof recreateResult !== "string") return recreateResult;
4888
+ if (recreateResult === "changed") swapChanged = true;
4889
+ const enableResult = await enableSwap(ssh2, options.path);
4890
+ if (typeof enableResult !== "boolean") return enableResult;
4891
+ if (enableResult) swapChanged = true;
4892
+ if (await ensureSwapFstabState({ desiredLine: options.expectedFstabLine, path: options.path, ssh: ssh2 })) {
4893
+ swapChanged = true;
4894
+ }
4895
+ return { status: swapChanged ? "changed" : "ok" };
4896
+ }
4897
+ async function applySwapFile(ssh2, options) {
4898
+ return options.state === "absent" ? applyAbsentSwapFile(ssh2, options) : applyPresentSwapFile(ssh2, options);
4899
+ }
4900
+ async function checkAbsent(ssh2, options) {
4901
+ if (await ssh2.exists(options.path)) return NEEDS_APPLY;
4902
+ if (await isSwapActive(ssh2, options.path)) return NEEDS_APPLY;
4903
+ return await hasNoSwapFstabEntry(ssh2, options.path) ? "ok" : NEEDS_APPLY;
4904
+ }
4905
+ async function checkPresent(ssh2, options) {
4906
+ if (await needsSwapRecreation(ssh2, options)) return NEEDS_APPLY;
4907
+ if (!await isSwapActive(ssh2, options.path)) return NEEDS_APPLY;
4908
+ return await hasSwapFstabEntry(ssh2, options) ? "ok" : NEEDS_APPLY;
4909
+ }
4910
+ async function checkSwapFile(ssh2, options) {
4911
+ return options.state === "absent" ? checkAbsent(ssh2, options) : checkPresent(ssh2, options);
4912
+ }
4913
+
4914
+ // src/modules/sysctl.ts
4915
+ var EXEC_OPTS10 = { ignoreExitCode: true, silent: true };
4379
4916
  var SYSCTL_DIR = "/etc/sysctl.d";
4380
4917
  var SYSCTL_CONFIG_MODE = "0644";
4381
4918
  function sanitizeKey(key) {
@@ -4411,28 +4948,28 @@ var sysctl = {
4411
4948
  if (!conn) return failed(`[sysctl.set: ${key}] SSH connection is required`);
4412
4949
  if (state === "present") {
4413
4950
  const assignment = `${key}=${value}`;
4414
- const result = await conn.exec(`sysctl -w ${shellQuote(assignment)}`, EXEC_OPTS8);
4951
+ const result = await conn.exec(`sysctl -w ${shellQuote(assignment)}`, EXEC_OPTS10);
4415
4952
  if (result.code !== 0) {
4416
4953
  return failedCommand(`[sysctl.set: ${key}] sysctl -w failed`, result);
4417
4954
  }
4418
4955
  await conn.writeFile(configPath, expectedContent, { mode: SYSCTL_CONFIG_MODE });
4419
4956
  } else {
4420
- await conn.exec(`rm -f ${shellQuote(configPath)}`, EXEC_OPTS8);
4957
+ await conn.exec(`rm -f ${shellQuote(configPath)}`, EXEC_OPTS10);
4421
4958
  }
4422
4959
  return { status: "changed" };
4423
4960
  },
4424
4961
  async check(conn) {
4425
4962
  if (!conn) return NEEDS_APPLY;
4426
4963
  if (state === "present") {
4427
- const result = await conn.exec(`sysctl -n ${shellQuote(key)}`, EXEC_OPTS8);
4964
+ const result = await conn.exec(`sysctl -n ${shellQuote(key)}`, EXEC_OPTS10);
4428
4965
  const currentValue = result.stdout.trim();
4429
4966
  if (currentValue !== value) return NEEDS_APPLY;
4430
- const configExists = await conn.exec(`test -f ${shellQuote(configPath)}`, EXEC_OPTS8);
4967
+ const configExists = await conn.exec(`test -f ${shellQuote(configPath)}`, EXEC_OPTS10);
4431
4968
  if (configExists.code !== 0) return NEEDS_APPLY;
4432
4969
  const fileContent = await conn.readFile(configPath);
4433
4970
  return fileContent.trim() === expectedContent.trim() ? "ok" : NEEDS_APPLY;
4434
4971
  }
4435
- const fileExists = await conn.exec(`test -f ${shellQuote(configPath)}`, EXEC_OPTS8);
4972
+ const fileExists = await conn.exec(`test -f ${shellQuote(configPath)}`, EXEC_OPTS10);
4436
4973
  return fileExists.code === 0 ? NEEDS_APPLY : "ok";
4437
4974
  },
4438
4975
  name: state === "present" ? `sysctl.set: ${key}=${value}` : `sysctl.set: absent ${key}`
@@ -4440,6 +4977,53 @@ var sysctl = {
4440
4977
  }
4441
4978
  };
4442
4979
 
4980
+ // src/modules/swap.ts
4981
+ var swap = {
4982
+ /**
4983
+ * Ensure a file-backed swap area exists, is activated, and is persisted in `/etc/fstab`.
4984
+ *
4985
+ * @param options - Configuration for the swap file.
4986
+ * @param options.mode - File mode applied to the swap file. Defaults to `0600`.
4987
+ * @param options.path - Absolute path to the swap file, e.g. `/swapfile`.
4988
+ * @param options.priority - Optional swap priority written into the fstab entry.
4989
+ * @param options.size - Desired file size as bytes or a shell-friendly size string such as `2G`.
4990
+ * @param options.state - Whether the swap file should be `present` (default) or `absent`.
4991
+ * @returns A Module that manages the swap file lifecycle.
4992
+ */
4993
+ file(options) {
4994
+ const normalized = normalizeSwapFileOptions(options);
4995
+ return {
4996
+ async apply(ssh2) {
4997
+ if (!ssh2) return failed(`[swap.file: ${normalized.path}] SSH connection is required`);
4998
+ return applySwapFile(ssh2, normalized);
4999
+ },
5000
+ async check(ssh2) {
5001
+ if (!ssh2) return NEEDS_APPLY;
5002
+ return checkSwapFile(ssh2, normalized);
5003
+ },
5004
+ name: normalized.state === "present" ? `swap.file: ${normalized.path} (${normalized.sizeForCommand})` : `swap.file: absent ${normalized.path}`
5005
+ };
5006
+ },
5007
+ /**
5008
+ * Persist `vm.swappiness`.
5009
+ *
5010
+ * @param value - Desired swappiness value.
5011
+ * @returns A Module that manages `vm.swappiness`.
5012
+ */
5013
+ swappiness(value) {
5014
+ return sysctl.set("vm.swappiness", String(value));
5015
+ },
5016
+ /**
5017
+ * Persist `vm.vfs_cache_pressure`.
5018
+ *
5019
+ * @param value - Desired VFS cache pressure value.
5020
+ * @returns A Module that manages `vm.vfs_cache_pressure`.
5021
+ */
5022
+ vfsCachePressure(value) {
5023
+ return sysctl.set("vm.vfs_cache_pressure", String(value));
5024
+ }
5025
+ };
5026
+
4443
5027
  // src/modules/system.ts
4444
5028
  function parseOsRelease(content) {
4445
5029
  const result = {};
@@ -4610,8 +5194,8 @@ ${message}`);
4610
5194
  };
4611
5195
 
4612
5196
  // src/modules/systemd.ts
4613
- var SYSTEMCTL3 = "systemctl";
4614
- var UNIT_NAME_PATTERN2 = new RegExp("^[\\w@.\\-]+$", "v");
5197
+ var SYSTEMCTL4 = "systemctl";
5198
+ var UNIT_NAME_PATTERN3 = new RegExp("^[\\w@.\\-]+$", "v");
4615
5199
  var SYSTEMD_UNIT_MODE2 = "0644";
4616
5200
  var systemd = {
4617
5201
  /**
@@ -4623,7 +5207,7 @@ var systemd = {
4623
5207
  return {
4624
5208
  async apply(ssh2) {
4625
5209
  if (!ssh2) return failed("[systemd.daemonReload] SSH connection is required");
4626
- const result = await ssh2.exec(`${SYSTEMCTL3} daemon-reload`, {
5210
+ const result = await ssh2.exec(`${SYSTEMCTL4} daemon-reload`, {
4627
5211
  ignoreExitCode: true,
4628
5212
  silent: true
4629
5213
  });
@@ -4645,7 +5229,7 @@ var systemd = {
4645
5229
  return {
4646
5230
  async apply(ssh2) {
4647
5231
  if (!ssh2) return failed(`[systemd.masked: ${name}] SSH connection is required`);
4648
- const result = await ssh2.exec(`${SYSTEMCTL3} mask ${shellQuote(name)}`, {
5232
+ const result = await ssh2.exec(`${SYSTEMCTL4} mask ${shellQuote(name)}`, {
4649
5233
  ignoreExitCode: true,
4650
5234
  silent: true
4651
5235
  });
@@ -4653,7 +5237,7 @@ var systemd = {
4653
5237
  },
4654
5238
  async check(ssh2) {
4655
5239
  if (!ssh2) return NEEDS_APPLY;
4656
- const result = await ssh2.exec(`${SYSTEMCTL3} is-enabled ${shellQuote(name)}`, {
5240
+ const result = await ssh2.exec(`${SYSTEMCTL4} is-enabled ${shellQuote(name)}`, {
4657
5241
  ignoreExitCode: true,
4658
5242
  silent: true
4659
5243
  });
@@ -4674,15 +5258,15 @@ var systemd = {
4674
5258
  * @returns A Module that ensures the unit file is present with the given content.
4675
5259
  */
4676
5260
  unit(name, content) {
4677
- if (!UNIT_NAME_PATTERN2.test(name)) {
4678
- throw new Error(`systemd.unit: name must match ${String(UNIT_NAME_PATTERN2)}, got: ${name}`);
5261
+ if (!UNIT_NAME_PATTERN3.test(name)) {
5262
+ throw new Error(`systemd.unit: name must match ${String(UNIT_NAME_PATTERN3)}, got: ${name}`);
4679
5263
  }
4680
5264
  const filePath = `/etc/systemd/system/${name}`;
4681
5265
  return {
4682
5266
  async apply(ssh2) {
4683
5267
  if (!ssh2) return failed(`[systemd.unit: ${name}] SSH connection is required`);
4684
5268
  await ssh2.writeFile(filePath, content, { mode: SYSTEMD_UNIT_MODE2 });
4685
- const result = await ssh2.exec(`${SYSTEMCTL3} daemon-reload`, {
5269
+ const result = await ssh2.exec(`${SYSTEMCTL4} daemon-reload`, {
4686
5270
  ignoreExitCode: true,
4687
5271
  silent: true
4688
5272
  });
@@ -4707,7 +5291,7 @@ var systemd = {
4707
5291
  return {
4708
5292
  async apply(ssh2) {
4709
5293
  if (!ssh2) return failed(`[systemd.unmasked: ${name}] SSH connection is required`);
4710
- const result = await ssh2.exec(`${SYSTEMCTL3} unmask ${shellQuote(name)}`, {
5294
+ const result = await ssh2.exec(`${SYSTEMCTL4} unmask ${shellQuote(name)}`, {
4711
5295
  ignoreExitCode: true,
4712
5296
  silent: true
4713
5297
  });
@@ -4715,7 +5299,7 @@ var systemd = {
4715
5299
  },
4716
5300
  async check(ssh2) {
4717
5301
  if (!ssh2) return NEEDS_APPLY;
4718
- const result = await ssh2.exec(`${SYSTEMCTL3} is-enabled ${shellQuote(name)}`, {
5302
+ const result = await ssh2.exec(`${SYSTEMCTL4} is-enabled ${shellQuote(name)}`, {
4719
5303
  ignoreExitCode: true,
4720
5304
  silent: true
4721
5305
  });
@@ -4979,6 +5563,7 @@ export {
4979
5563
  mount,
4980
5564
  net,
4981
5565
  op,
5566
+ quadlet,
4982
5567
  releaseUpgrade,
4983
5568
  rsync,
4984
5569
  script,
@@ -4986,9 +5571,10 @@ export {
4986
5571
  ssh,
4987
5572
  sshd,
4988
5573
  sysctl,
5574
+ swap,
4989
5575
  system,
4990
5576
  systemd,
4991
5577
  ufw,
4992
5578
  user
4993
5579
  };
4994
- //# sourceMappingURL=chunk-C45YPXCX.js.map
5580
+ //# sourceMappingURL=chunk-LI47NIKN.js.map