@stacksjs/ts-cloud 0.7.16 → 0.7.18

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.
@@ -545,6 +545,473 @@ function buildNginxServiceScript(projectDir = "/opt/pantry") {
545
545
  ];
546
546
  }
547
547
 
548
+ // src/deploy/site-target.ts
549
+ function siteInstallBase(slug, siteName) {
550
+ return `/var/www/${slug}-${siteName}`;
551
+ }
552
+ var PHP_SITE_TYPES = new Set([
553
+ "laravel",
554
+ "php",
555
+ "statamic",
556
+ "wordpress"
557
+ ]);
558
+ function isPhpSite(site) {
559
+ return site.type != null && PHP_SITE_TYPES.has(site.type);
560
+ }
561
+ function resolveSiteDeployTarget(site) {
562
+ if (site.deploy)
563
+ return site.deploy;
564
+ if (isPhpSite(site))
565
+ return "server";
566
+ if (site.start)
567
+ return "server";
568
+ return "bucket";
569
+ }
570
+ function resolveSiteKind(site) {
571
+ if (site.redirect)
572
+ return "redirect";
573
+ if (isPhpSite(site))
574
+ return "server-php";
575
+ const target = resolveSiteDeployTarget(site);
576
+ if (target === "bucket")
577
+ return "bucket";
578
+ return site.start ? "server-app" : "server-static";
579
+ }
580
+ function hasComputeConfigured(config) {
581
+ return config.infrastructure?.compute != null;
582
+ }
583
+ function validateDeploymentConfig(config) {
584
+ const errors = [];
585
+ const warnings = [];
586
+ const sites = config.sites || {};
587
+ const computeConfigured = hasComputeConfigured(config);
588
+ const coexistence = deploymentCoexistenceError(config);
589
+ if (coexistence)
590
+ errors.push(coexistence);
591
+ const portOwners = new Map;
592
+ for (const [name, site] of Object.entries(sites)) {
593
+ if (!site) {
594
+ continue;
595
+ }
596
+ const target = resolveSiteDeployTarget(site);
597
+ const kind = resolveSiteKind(site);
598
+ if (kind === "redirect") {
599
+ if (!site.domain)
600
+ errors.push(`Site '${name}' is a redirect site but has no \`domain\` to redirect from.`);
601
+ const to = typeof site.redirect === "string" ? site.redirect : site.redirect?.to;
602
+ if (!to)
603
+ errors.push(`Site '${name}' is a redirect site but has no redirect target (\`redirect\` / \`redirect.to\`).`);
604
+ if (!computeConfigured) {
605
+ errors.push(`Site '${name}' is a redirect site but no \`infrastructure.compute\` is configured to host the gateway that serves the redirect.`);
606
+ }
607
+ const serverOnly = [];
608
+ if (site.start)
609
+ serverOnly.push("start");
610
+ if (site.root)
611
+ serverOnly.push("root");
612
+ if (serverOnly.length > 0)
613
+ warnings.push(`Site '${name}' is a redirect site but also sets ${serverOnly.join(", ")}. These are ignored.`);
614
+ continue;
615
+ }
616
+ if (target === "server" && !site.start && !site.root) {
617
+ errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
618
+ continue;
619
+ }
620
+ if (kind === "server-php") {
621
+ if (!computeConfigured) {
622
+ errors.push(`Site '${name}' is a PHP site (type:'${site.type}') but no \`infrastructure.compute\` is configured. Add a server (infrastructure.compute) with PHP provisioning.`);
623
+ }
624
+ if (!site.repository?.url) {
625
+ errors.push(`Site '${name}' is a PHP site (type:'${site.type}') but has no \`repository.url\` to clone. PHP sites deploy via git.`);
626
+ }
627
+ } else if (kind === "server-app") {
628
+ if (!computeConfigured) {
629
+ errors.push(`Site '${name}' deploys to a server (deploy:'server'${site.deploy ? "" : " inferred from `start`"}) but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
630
+ }
631
+ if (typeof site.port === "number") {
632
+ const existing = portOwners.get(site.port);
633
+ if (existing) {
634
+ errors.push(`Sites '${existing}' and '${name}' both use port ${site.port}. Server apps sharing a box must use distinct ports.`);
635
+ } else {
636
+ portOwners.set(site.port, name);
637
+ }
638
+ }
639
+ } else if (kind === "server-static") {
640
+ if (!site.root) {
641
+ errors.push(`Site '${name}' is a server static site (deploy:'server', no \`start\`) but has no \`root\` directory to serve.`);
642
+ }
643
+ if (!computeConfigured) {
644
+ errors.push(`Site '${name}' deploys to a server (deploy:'server') but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
645
+ }
646
+ } else {
647
+ if (!site.root) {
648
+ errors.push(`Site '${name}' deploys to a bucket but has no \`root\` directory to upload.`);
649
+ }
650
+ const serverOnly = [];
651
+ if (site.start)
652
+ serverOnly.push("start");
653
+ if (typeof site.port === "number")
654
+ serverOnly.push("port");
655
+ if (site.preStart && site.preStart.length > 0)
656
+ serverOnly.push("preStart");
657
+ if (serverOnly.length > 0) {
658
+ warnings.push(`Site '${name}' deploys to a bucket but sets server-only field(s): ${serverOnly.join(", ")}. These are ignored. Set deploy:'server' to use them.`);
659
+ }
660
+ }
661
+ }
662
+ return { errors, warnings };
663
+ }
664
+
665
+ // src/drivers/shared/rpx-gateway.ts
666
+ var DEFAULT_RPX_CERTS_DIR = "/etc/rpx/certs";
667
+ var DEFAULT_ACME_WEBROOT = "/var/www/acme-challenge";
668
+ function normalizeSiteRedirect(input) {
669
+ if (typeof input === "string")
670
+ return { to: input };
671
+ const out = { to: input.to };
672
+ if (input.status != null)
673
+ out.status = input.status;
674
+ if (input.preservePath != null)
675
+ out.preservePath = input.preservePath;
676
+ return out;
677
+ }
678
+ function resolveRouteAuth(site) {
679
+ const auth = site.auth;
680
+ if (!auth || auth.enabled === false || !auth.password)
681
+ return;
682
+ return {
683
+ username: auth.username || "admin",
684
+ password: auth.password,
685
+ ...auth.realm ? { realm: auth.realm } : {}
686
+ };
687
+ }
688
+ function normalizeRoutePath(path) {
689
+ if (!path || path === "/")
690
+ return;
691
+ let p = `/${path}`.replace(/\/+/g, "/").replace(/\/+$/, "");
692
+ if (!p.startsWith("/"))
693
+ p = `/${p}`;
694
+ return p === "" || p === "/" ? undefined : p;
695
+ }
696
+ function deriveRouteId(to, path) {
697
+ const base = path ? `${to}${path}` : to;
698
+ const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128);
699
+ return cleaned.length > 0 ? cleaned : "rpx";
700
+ }
701
+ function resolveServerAppFrom(port, appBoxes) {
702
+ if (!appBoxes || appBoxes.length === 0)
703
+ return `localhost:${port}`;
704
+ return appBoxes.map((box) => `${box.privateIp ?? box.publicIp}:${port}`);
705
+ }
706
+ function buildRpxConfigInternal(sites, options, appBoxes) {
707
+ const wwwRoot = (options.wwwRoot ?? "/var/www").replace(/\/+$/, "");
708
+ const installSlug = options.slug ?? "app";
709
+ const certsDir = options.proxy.certsDir ?? DEFAULT_RPX_CERTS_DIR;
710
+ const loadBalancer = options.proxy.loadBalancer;
711
+ const proxies = [];
712
+ const domains = new Set;
713
+ for (const [name, site] of Object.entries(sites)) {
714
+ if (!site || !site.domain)
715
+ continue;
716
+ const kind = resolveSiteKind(site);
717
+ if (kind === "bucket")
718
+ continue;
719
+ const path = normalizeRoutePath(site.path);
720
+ const id = deriveRouteId(site.domain, path);
721
+ const auth = resolveRouteAuth(site);
722
+ if (kind === "redirect") {
723
+ proxies.push({ to: site.domain, path, redirect: normalizeSiteRedirect(site.redirect), id, ...auth ? { auth } : {} });
724
+ domains.add(site.domain);
725
+ continue;
726
+ }
727
+ if (kind === "server-app") {
728
+ if (typeof site.port !== "number")
729
+ continue;
730
+ const from = resolveServerAppFrom(site.port, appBoxes);
731
+ proxies.push({
732
+ to: site.domain,
733
+ path,
734
+ from,
735
+ id,
736
+ ...auth ? { auth } : {},
737
+ ...Array.isArray(from) && loadBalancer ? { loadBalancer } : {}
738
+ });
739
+ } else {
740
+ proxies.push({
741
+ to: site.domain,
742
+ path,
743
+ static: {
744
+ dir: `${wwwRoot}/${installSlug}-${name}/current`,
745
+ spa: site.spa ?? false,
746
+ pathRewriteStyle: site.pathRewriteStyle ?? "directory"
747
+ },
748
+ cleanUrls: site.pathRewriteStyle !== "flat",
749
+ ...auth ? { auth } : {},
750
+ id
751
+ });
752
+ }
753
+ domains.add(site.domain);
754
+ }
755
+ if (options.proxy.autoWww !== false) {
756
+ for (const domain of [...domains]) {
757
+ if (domain.split(".").length !== 2)
758
+ continue;
759
+ const wwwDomain = `www.${domain}`;
760
+ if (domains.has(wwwDomain))
761
+ continue;
762
+ proxies.push({ to: wwwDomain, redirect: { to: `https://${domain}` }, id: deriveRouteId(wwwDomain) });
763
+ domains.add(wwwDomain);
764
+ }
765
+ }
766
+ proxies.sort((a, b) => {
767
+ if (a.to !== b.to)
768
+ return a.to.localeCompare(b.to);
769
+ return (b.path?.length ?? 0) - (a.path?.length ?? 0);
770
+ });
771
+ const config = {
772
+ proxies,
773
+ productionCerts: { certsDir },
774
+ https: true,
775
+ hostsManagement: false,
776
+ cleanup: { hosts: false, certs: false }
777
+ };
778
+ if (options.proxy.onDemandTls && domains.size > 0) {
779
+ config.onDemandTls = {
780
+ enabled: true,
781
+ allowedSuffixes: [...domains],
782
+ email: options.proxy.onDemandTlsEmail,
783
+ certsDir
784
+ };
785
+ }
786
+ if (options.proxy.onDemandTls) {
787
+ config.acmeChallengeWebroot = options.proxy.acmeWebroot ?? DEFAULT_ACME_WEBROOT;
788
+ }
789
+ const cdn = options.proxy.cdn;
790
+ if (cdn?.secret && cdn.frontedHosts.length > 0) {
791
+ config.originGuard = {
792
+ header: cdn.secretHeader ?? "X-Origin-Verify",
793
+ value: cdn.secret,
794
+ hosts: cdn.frontedHosts
795
+ };
796
+ }
797
+ return config;
798
+ }
799
+ function buildRpxConfig(sites, options) {
800
+ return buildRpxConfigInternal(sites, options);
801
+ }
802
+ function buildRpxLbConfig(sites, appBoxes, options) {
803
+ return buildRpxConfigInternal(sites, options, appBoxes);
804
+ }
805
+ function renderRpxLauncher(config) {
806
+ const json = JSON.stringify(config, null, 2);
807
+ return `// Generated by ts-cloud — rpx reverse-proxy gateway.
808
+ // Routes are derived from the \`sites\` model on every \`buddy deploy\`.
809
+ import { startProxies } from '@stacksjs/rpx'
810
+
811
+ const config = ${json} as const
812
+
813
+ await startProxies(config as any)
814
+ `;
815
+ }
816
+ function usesRpxProxy(compute) {
817
+ return compute?.webServer === "rpx" || compute?.proxy?.engine === "rpx";
818
+ }
819
+ var RPX_DIR = "/etc/rpx";
820
+ var RPX_INSTALL_DIR = "/opt/rpx-gateway";
821
+ var RPX_LAUNCHER_PATH = "/etc/rpx/gateway.ts";
822
+ var RPX_SERVICE_NAME = "rpx-gateway.service";
823
+ var RPX_SITES_DIR = "/etc/rpx/sites.d";
824
+ function renderRpxAssembler(sitesDir = RPX_SITES_DIR, defaultCertsDir = DEFAULT_RPX_CERTS_DIR) {
825
+ return `// Generated by ts-cloud — rpx gateway assembler.
826
+ // Merges every app's fragment in ${sitesDir} so independent deploys compose
827
+ // without clobbering each other. Each deploy writes only its own <slug>.json.
828
+ import { startProxies } from '@stacksjs/rpx'
829
+ import { readdirSync, readFileSync } from 'node:fs'
830
+
831
+ const dir = ${JSON.stringify(sitesDir)}
832
+ const proxies = []
833
+ const seen = new Set()
834
+ const suffixes = new Set()
835
+ const guardHosts = new Set()
836
+ let email
837
+ let certsDir = ${JSON.stringify(defaultCertsDir)}
838
+ let acmeChallengeWebroot
839
+ let guard
840
+ let files = []
841
+ try { files = readdirSync(dir).filter(n => n.endsWith('.json')).sort() } catch {}
842
+ for (const f of files) {
843
+ let frag
844
+ try { frag = JSON.parse(readFileSync(dir + '/' + f, 'utf8')) } catch { continue }
845
+ for (const p of frag.proxies ?? []) {
846
+ const key = p.id || (p.to + (p.path ?? ''))
847
+ if (seen.has(key)) continue
848
+ seen.add(key)
849
+ proxies.push(p)
850
+ }
851
+ for (const s of frag.onDemandTls?.allowedSuffixes ?? []) suffixes.add(s)
852
+ email ??= frag.onDemandTls?.email
853
+ if (frag.productionCerts?.certsDir) certsDir = frag.productionCerts.certsDir
854
+ acmeChallengeWebroot ??= frag.acmeChallengeWebroot
855
+ if (frag.originGuard) {
856
+ guard ??= { header: frag.originGuard.header, value: frag.originGuard.value }
857
+ for (const h of frag.originGuard.hosts ?? []) guardHosts.add(h)
858
+ }
859
+ }
860
+ const config = {
861
+ proxies,
862
+ productionCerts: { certsDir },
863
+ https: true,
864
+ hostsManagement: false,
865
+ cleanup: { hosts: false, certs: false },
866
+ ...(suffixes.size > 0 ? { onDemandTls: { enabled: true, allowedSuffixes: [...suffixes], email, certsDir } } : {}),
867
+ ...(acmeChallengeWebroot ? { acmeChallengeWebroot } : {}),
868
+ ...(guard ? { originGuard: { header: guard.header, value: guard.value, hosts: [...guardHosts] } } : {}),
869
+ }
870
+
871
+ await startProxies(config)
872
+ `;
873
+ }
874
+ function writeFileHeredoc(path, content, delimiter) {
875
+ return [
876
+ `cat > ${path} <<'${delimiter}'`,
877
+ content,
878
+ delimiter
879
+ ];
880
+ }
881
+ function certDomainsForConfig(config) {
882
+ const seen = new Set;
883
+ for (const r of config.proxies) {
884
+ const host = r.to;
885
+ if (!host || host.startsWith("*") || host.includes(":") || !host.includes("."))
886
+ continue;
887
+ seen.add(host);
888
+ }
889
+ return [...seen];
890
+ }
891
+ function buildCertManagementCommands(options) {
892
+ const { config, proxy } = options;
893
+ const webroot = config.acmeChallengeWebroot;
894
+ const domains = certDomainsForConfig(config);
895
+ if (!proxy.onDemandTls || !webroot || domains.length === 0)
896
+ return [];
897
+ const bunBin = options.bunBin ?? "/usr/local/bin/bun";
898
+ const version = proxy.version ?? "latest";
899
+ const certsDir = config.productionCerts.certsDir;
900
+ const email = proxy.onDemandTlsEmail ?? `webmaster@${domains[0]}`;
901
+ const tlsxCli = `${bunBin} ${RPX_INSTALL_DIR}/node_modules/@stacksjs/tlsx/dist/bin/cli.js`;
902
+ const csv = domains.join(",");
903
+ const spaced = domains.join(" ");
904
+ const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
905
+ const renewScriptPath = `${RPX_DIR}/renew-certs-${slug}.sh`;
906
+ const renewServiceName = `rpx-cert-renew-${slug}.service`;
907
+ const renewTimerName = `rpx-cert-renew-${slug}.timer`;
908
+ const renewScript = [
909
+ "#!/bin/sh",
910
+ "# Generated by ts-cloud — issue/renew rpx gateway TLS certs via tlsx http-01.",
911
+ "# The running gateway serves the challenge from $WEBROOT on :80, so this needs",
912
+ "# no downtime and no DNS credentials. Reloads the gateway only if a cert changed.",
913
+ "set -u",
914
+ `CERTS='${certsDir}'`,
915
+ `WEBROOT='${webroot}'`,
916
+ `EMAIL='${email}'`,
917
+ `TLSX="${tlsxCli}"`,
918
+ `DOMAINS='${csv}'`,
919
+ 'before=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
920
+ `for d in ${spaced}; do`,
921
+ ' [ -s "$CERTS/$d.crt" ] || $TLSX acme:issue -d "$d" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --prod --email "$EMAIL" || echo "issue $d failed (non-fatal)"',
922
+ "done",
923
+ '$TLSX acme:renew --domains "$DOMAINS" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --days 30 --prod --email "$EMAIL" || echo "renew: some domains failed (non-fatal)"',
924
+ 'rm -f "$CERTS"/*.chain.crt',
925
+ 'after=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
926
+ `[ "$before" = "$after" ] || systemctl restart ${RPX_SERVICE_NAME}`
927
+ ].join(`
928
+ `);
929
+ const renewService = [
930
+ "[Unit]",
931
+ `Description=Issue/renew rpx gateway TLS certs for ${slug} (tlsx http-01)`,
932
+ `After=network-online.target ${RPX_SERVICE_NAME}`,
933
+ "Wants=network-online.target",
934
+ "",
935
+ "[Service]",
936
+ "Type=oneshot",
937
+ `ExecStart=${renewScriptPath}`
938
+ ].join(`
939
+ `);
940
+ const renewTimer = [
941
+ "[Unit]",
942
+ `Description=Daily rpx gateway TLS cert issuance/renewal for ${slug}`,
943
+ "",
944
+ "[Timer]",
945
+ "OnCalendar=*-*-* 03:30:00",
946
+ "RandomizedDelaySec=1h",
947
+ "Persistent=true",
948
+ "",
949
+ "[Install]",
950
+ "WantedBy=timers.target"
951
+ ].join(`
952
+ `);
953
+ return [
954
+ `mkdir -p ${webroot}`,
955
+ `(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/tlsx@${version}) || true`,
956
+ ...writeFileHeredoc(renewScriptPath, renewScript, "TS_CLOUD_RENEW_EOF"),
957
+ `chmod +x ${renewScriptPath}`,
958
+ ...writeFileHeredoc(`/etc/systemd/system/${renewServiceName}`, renewService, "TS_CLOUD_RENEW_SVC_EOF"),
959
+ ...writeFileHeredoc(`/etc/systemd/system/${renewTimerName}`, renewTimer, "TS_CLOUD_RENEW_TIMER_EOF"),
960
+ "systemctl daemon-reload",
961
+ `systemctl enable --now ${renewTimerName} || true`,
962
+ `${renewScriptPath} || true`
963
+ ];
964
+ }
965
+ function buildRpxProvisionScript(options) {
966
+ const { config, proxy } = options;
967
+ const bunBin = options.bunBin ?? "/usr/local/bin/bun";
968
+ const version = proxy.version ?? "latest";
969
+ const certsDir = config.productionCerts.certsDir;
970
+ const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
971
+ const fragment = JSON.stringify({ slug, ...config }, null, 2);
972
+ const assembler = renderRpxAssembler(RPX_SITES_DIR, certsDir);
973
+ const upstreamTimeout = proxy.upstreamTimeout ?? 60;
974
+ const poolEnv = [`Environment=RPX_UPSTREAM_TIMEOUT=${upstreamTimeout}`];
975
+ if (typeof proxy.maxUpstreamConns === "number")
976
+ poolEnv.push(`Environment=RPX_MAX_UPSTREAM_CONNS=${proxy.maxUpstreamConns}`);
977
+ return [
978
+ "set -euo pipefail",
979
+ `mkdir -p ${RPX_DIR} ${RPX_SITES_DIR} ${certsDir} ${RPX_INSTALL_DIR}`,
980
+ `rm -rf ${RPX_INSTALL_DIR}/node_modules ${RPX_INSTALL_DIR}/bun.lock ${RPX_INSTALL_DIR}/package.json`,
981
+ `(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/rpx@${version})`,
982
+ `ln -sfn ${RPX_INSTALL_DIR}/node_modules ${RPX_DIR}/node_modules`,
983
+ ...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF"),
984
+ ...writeFileHeredoc(RPX_LAUNCHER_PATH, assembler, "TS_CLOUD_RPX_EOF"),
985
+ ...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
986
+ "[Unit]",
987
+ "Description=rpx reverse-proxy gateway (managed by ts-cloud)",
988
+ "After=network.target network-online.target",
989
+ "Wants=network-online.target",
990
+ "",
991
+ "[Service]",
992
+ "Type=simple",
993
+ `ExecStart=${bunBin} ${RPX_LAUNCHER_PATH}`,
994
+ `WorkingDirectory=${RPX_INSTALL_DIR}`,
995
+ `Environment=BUN_INSTALL=/root/.bun`,
996
+ ...poolEnv,
997
+ "Restart=always",
998
+ "RestartSec=5",
999
+ "LimitNOFILE=1048576",
1000
+ "AmbientCapabilities=CAP_NET_BIND_SERVICE",
1001
+ "",
1002
+ "[Install]",
1003
+ "WantedBy=multi-user.target"
1004
+ ].join(`
1005
+ `), "TS_CLOUD_RPX_UNIT_EOF"),
1006
+ "systemctl daemon-reload",
1007
+ "systemctl disable --now bun-gateway.service 2>/dev/null || true",
1008
+ "systemctl disable --now ts-cloud-nginx.service 2>/dev/null || true",
1009
+ `systemctl enable ${RPX_SERVICE_NAME}`,
1010
+ `systemctl restart ${RPX_SERVICE_NAME}`,
1011
+ ...buildCertManagementCommands(options)
1012
+ ];
1013
+ }
1014
+
548
1015
  // src/drivers/shared/ufw.ts
549
1016
  var UFW_BASE_PORTS = [80, 443];
550
1017
  function buildUfwScript(firewall = {}) {
@@ -909,7 +1376,7 @@ function buildComputeProvisionScripts(config) {
909
1376
  const phpBox = compute.runtime === "php" || !!compute.php;
910
1377
  const needsPantry = phpBox || !!compute.managedServices;
911
1378
  const pantryBootstrap = needsPantry ? buildPantryBootstrapScript() : [];
912
- const useNginx = compute.webServer !== "rpx";
1379
+ const useNginx = !usesRpxProxy(compute);
913
1380
  const phpProvision = phpBox ? [
914
1381
  ...pantryBootstrap,
915
1382
  ...buildPhpProvisionScript({
@@ -1355,768 +1822,307 @@ class AwsDriver {
1355
1822
  const nameTag = instance.Tags?.find((tag) => tag.Key === "Name")?.Value;
1356
1823
  targets.push({
1357
1824
  id: instance.InstanceId,
1358
- name: nameTag,
1359
- publicIp: instance.PublicIpAddress,
1360
- privateIp: instance.PrivateIpAddress,
1361
- status: instance.State?.Name
1362
- });
1363
- }
1364
- }
1365
- return targets;
1366
- }
1367
- bashWrap(commands) {
1368
- return ["bash <<'TS_CLOUD_BASH_EOF'", commands.join(`
1369
- `), "TS_CLOUD_BASH_EOF"];
1370
- }
1371
- async runRemoteDeploy(options) {
1372
- const region = this.region;
1373
- const ssm = new SSMClient(region);
1374
- if (options.targets.length > 0) {
1375
- const sendResult = await ssm.sendCommand({
1376
- InstanceIds: options.targets.map((target) => target.id),
1377
- DocumentName: "AWS-RunShellScript",
1378
- Parameters: { commands: this.bashWrap(options.commands) },
1379
- TimeoutSeconds: options.timeoutSeconds || 600,
1380
- Comment: options.comment
1381
- });
1382
- if (!sendResult.CommandId) {
1383
- return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
1384
- }
1385
- return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
1386
- }
1387
- if (!options.tags || Object.keys(options.tags).length === 0) {
1388
- return { success: false, instanceCount: 0, perInstance: [], error: "No targets or tags provided for AWS deploy" };
1389
- }
1390
- const result = await ssm.sendCommandByTags({
1391
- tags: options.tags,
1392
- commands: this.bashWrap(options.commands),
1393
- timeoutSeconds: options.timeoutSeconds || 600,
1394
- comment: options.comment
1395
- });
1396
- return {
1397
- success: result.success,
1398
- instanceCount: result.instanceCount,
1399
- perInstance: result.perInstance.map((item) => ({
1400
- instanceId: item.instanceId,
1401
- status: item.status,
1402
- output: item.output,
1403
- error: item.error
1404
- })),
1405
- error: result.error
1406
- };
1407
- }
1408
- async pollSsmCommand(ssm, commandId, expectedCount) {
1409
- const pollInterval = 3000;
1410
- const maxWait = 600000;
1411
- const startTime = Date.now();
1412
- const terminalStatuses = new Set(["Success", "Failed", "Cancelled", "TimedOut"]);
1413
- let lastInvocations = [];
1414
- while (Date.now() - startTime < maxWait) {
1415
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
1416
- try {
1417
- const invocations = await ssm.listCommandInvocations({ CommandId: commandId, Details: true });
1418
- lastInvocations = invocations;
1419
- if (lastInvocations.length >= expectedCount && lastInvocations.every((i) => terminalStatuses.has(i.Status || ""))) {
1420
- break;
1421
- }
1422
- } catch {}
1423
- }
1424
- const perInstance = lastInvocations.map((item) => ({
1425
- instanceId: item.InstanceId,
1426
- status: item.Status || "Unknown",
1427
- output: item.StandardOutputContent,
1428
- error: item.StandardErrorContent
1429
- }));
1430
- const success = perInstance.length > 0 && perInstance.every((item) => item.status === "Success");
1431
- return {
1432
- success,
1433
- instanceCount: perInstance.length,
1434
- perInstance,
1435
- error: success ? undefined : "One or more SSM command invocations failed"
1436
- };
1437
- }
1438
- }
1439
-
1440
- // src/drivers/hetzner/driver.ts
1441
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1442
- import { homedir } from "node:os";
1443
- import { join as join3 } from "node:path";
1444
- import { execSync } from "node:child_process";
1445
-
1446
- // src/drivers/hetzner/client.ts
1447
- var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
1448
-
1449
- class HetznerClient {
1450
- name = "hetzner";
1451
- apiToken;
1452
- baseUrl;
1453
- fetchImpl;
1454
- constructor(options) {
1455
- this.apiToken = options.apiToken;
1456
- this.baseUrl = options.baseUrl ?? DEFAULT_API_URL;
1457
- this.fetchImpl = options.fetchImpl ?? fetch;
1458
- }
1459
- async request(method, path, body) {
1460
- const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
1461
- method,
1462
- headers: {
1463
- Authorization: `Bearer ${this.apiToken}`,
1464
- "Content-Type": "application/json"
1465
- },
1466
- body: body === undefined ? undefined : JSON.stringify(body)
1467
- });
1468
- const text = await response.text();
1469
- let data;
1470
- try {
1471
- data = text ? JSON.parse(text) : {};
1472
- } catch {
1473
- if (!response.ok) {
1474
- const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
1475
- throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
1476
- }
1477
- throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
1478
- }
1479
- if (!response.ok) {
1480
- const message = data.error?.message || response.statusText || "Hetzner API error";
1481
- const code = data.error?.code ? ` [${data.error.code}]` : "";
1482
- throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
1483
- }
1484
- return data;
1485
- }
1486
- async listServers() {
1487
- const data = await this.request("GET", "/servers");
1488
- return data.servers;
1489
- }
1490
- async getServer(id) {
1491
- const data = await this.request("GET", `/servers/${id}`);
1492
- return data.server;
1493
- }
1494
- async createServer(options) {
1495
- const data = await this.request("POST", "/servers", {
1496
- name: options.name,
1497
- server_type: options.serverType,
1498
- image: options.image,
1499
- location: options.location,
1500
- datacenter: options.datacenter,
1501
- ssh_keys: options.sshKeys,
1502
- user_data: options.userData,
1503
- labels: options.labels,
1504
- firewalls: options.firewalls,
1505
- networks: options.networks,
1506
- start_after_create: true
1507
- });
1508
- return { server: data.server, action: data.action };
1509
- }
1510
- async listNetworks() {
1511
- const data = await this.request("GET", "/networks");
1512
- return data.networks;
1513
- }
1514
- async createNetwork(options) {
1515
- const ipRange = options.ipRange ?? "10.0.0.0/16";
1516
- const data = await this.request("POST", "/networks", {
1517
- name: options.name,
1518
- ip_range: ipRange,
1519
- subnets: [{ type: "cloud", ip_range: ipRange, network_zone: "eu-central" }],
1520
- labels: options.labels
1521
- });
1522
- return data.network;
1523
- }
1524
- async deleteNetwork(id) {
1525
- await this.request("DELETE", `/networks/${id}`);
1526
- }
1527
- async listLoadBalancers() {
1528
- const data = await this.request("GET", "/load_balancers");
1529
- return data.load_balancers;
1530
- }
1531
- async createLoadBalancer(options) {
1532
- const data = await this.request("POST", "/load_balancers", {
1533
- name: options.name,
1534
- load_balancer_type: options.type ?? "lb11",
1535
- location: options.location,
1536
- network_zone: options.network ? undefined : options.networkZone ?? "eu-central",
1537
- network: options.network,
1538
- labels: options.labels,
1539
- targets: [{ type: "label_selector", label_selector: { selector: options.labelSelector }, use_private_ip: !!options.network }],
1540
- services: options.services.map((s) => ({
1541
- protocol: s.protocol ?? "tcp",
1542
- listen_port: s.listenPort,
1543
- destination_port: s.destinationPort,
1544
- health_check: {
1545
- protocol: "http",
1546
- port: 80,
1547
- interval: 15,
1548
- timeout: 10,
1549
- retries: 3,
1550
- http: { path: "/", status_codes: ["2??", "3??"] }
1551
- }
1552
- }))
1553
- });
1554
- return data.load_balancer;
1555
- }
1556
- async deleteLoadBalancer(id) {
1557
- await this.request("DELETE", `/load_balancers/${id}`);
1558
- }
1559
- async deleteServer(id) {
1560
- const data = await this.request("DELETE", `/servers/${id}`);
1561
- return data.action;
1562
- }
1563
- async listFirewalls() {
1564
- const data = await this.request("GET", "/firewalls");
1565
- return data.firewalls;
1566
- }
1567
- async createFirewall(options) {
1568
- const data = await this.request("POST", "/firewalls", {
1569
- name: options.name,
1570
- rules: options.rules,
1571
- labels: options.labels,
1572
- apply_to: options.applyTo
1573
- });
1574
- return { firewall: data.firewall, actions: data.actions };
1575
- }
1576
- async setFirewallRules(firewallId, rules) {
1577
- const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
1578
- rules
1579
- });
1580
- return data.actions ?? [];
1581
- }
1582
- async deleteFirewall(firewallId) {
1583
- await this.request("DELETE", `/firewalls/${firewallId}`);
1584
- }
1585
- async applyFirewallToResources(firewallId, applyTo) {
1586
- const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
1587
- apply_to: applyTo
1588
- });
1589
- return data.actions;
1590
- }
1591
- async listSshKeys() {
1592
- const data = await this.request("GET", "/ssh_keys");
1593
- return data.ssh_keys;
1594
- }
1595
- async createSshKey(options) {
1596
- const data = await this.request("POST", "/ssh_keys", {
1597
- name: options.name,
1598
- public_key: options.publicKey,
1599
- labels: options.labels
1600
- });
1601
- return data.ssh_key;
1602
- }
1603
- async waitForAction(actionId, options) {
1604
- const pollInterval = options?.pollIntervalMs ?? 2000;
1605
- const maxWait = options?.maxWaitMs ?? 300000;
1606
- const start = Date.now();
1607
- while (Date.now() - start < maxWait) {
1608
- const data = await this.request("GET", `/actions/${actionId}`);
1609
- if (data.action.status === "success")
1610
- return data.action;
1611
- if (data.action.status === "error") {
1612
- throw new Error(data.action.error?.message || "Hetzner action failed");
1825
+ name: nameTag,
1826
+ publicIp: instance.PublicIpAddress,
1827
+ privateIp: instance.PrivateIpAddress,
1828
+ status: instance.State?.Name
1829
+ });
1613
1830
  }
1614
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
1615
- }
1616
- throw new Error(`Timed out waiting for Hetzner action ${actionId}`);
1617
- }
1618
- async waitForServerRunning(serverId, options) {
1619
- const pollInterval = options?.pollIntervalMs ?? 3000;
1620
- const maxWait = options?.maxWaitMs ?? 600000;
1621
- const start = Date.now();
1622
- while (Date.now() - start < maxWait) {
1623
- const server = await this.getServer(serverId);
1624
- if (server.status === "running")
1625
- return server;
1626
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
1627
1831
  }
1628
- throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
1832
+ return targets;
1629
1833
  }
1630
- }
1631
- function resolveHetznerApiToken(configToken) {
1632
- const token = configToken || process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN;
1633
- if (!token) {
1634
- throw new Error("Hetzner API token required. Set hetzner.apiToken in cloud.config.ts or HCLOUD_TOKEN / HETZNER_API_TOKEN.");
1834
+ bashWrap(commands) {
1835
+ return ["bash <<'TS_CLOUD_BASH_EOF'", commands.join(`
1836
+ `), "TS_CLOUD_BASH_EOF"];
1635
1837
  }
1636
- return token;
1637
- }
1638
- function normalizeSshPublicKey(publicKey) {
1639
- const [type, body] = publicKey.trim().split(/\s+/);
1640
- return body ? `${type} ${body}` : type;
1641
- }
1642
-
1643
- // src/drivers/hetzner/cloud-init.ts
1644
- function wrapCloudInitUserData(bootstrapScript) {
1645
- const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
1646
- const indented = bootstrapScript.split(`
1647
- `).map((line) => ` ${line}`).join(`
1648
- `);
1649
- return `#cloud-config
1650
- write_files:
1651
- - path: ${scriptPath}
1652
- permissions: '0755'
1653
- owner: root:root
1654
- content: |
1655
- ${indented}
1656
- runcmd:
1657
- - [ bash, ${scriptPath} ]
1658
- `;
1659
- }
1660
-
1661
- // src/deploy/site-target.ts
1662
- function siteInstallBase(slug, siteName) {
1663
- return `/var/www/${slug}-${siteName}`;
1664
- }
1665
- var PHP_SITE_TYPES = new Set([
1666
- "laravel",
1667
- "php",
1668
- "statamic",
1669
- "wordpress"
1670
- ]);
1671
- function isPhpSite(site) {
1672
- return site.type != null && PHP_SITE_TYPES.has(site.type);
1673
- }
1674
- function resolveSiteDeployTarget(site) {
1675
- if (site.deploy)
1676
- return site.deploy;
1677
- if (isPhpSite(site))
1678
- return "server";
1679
- if (site.start)
1680
- return "server";
1681
- return "bucket";
1682
- }
1683
- function resolveSiteKind(site) {
1684
- if (site.redirect)
1685
- return "redirect";
1686
- if (isPhpSite(site))
1687
- return "server-php";
1688
- const target = resolveSiteDeployTarget(site);
1689
- if (target === "bucket")
1690
- return "bucket";
1691
- return site.start ? "server-app" : "server-static";
1692
- }
1693
- function hasComputeConfigured(config) {
1694
- return config.infrastructure?.compute != null;
1695
- }
1696
- function validateDeploymentConfig(config) {
1697
- const errors = [];
1698
- const warnings = [];
1699
- const sites = config.sites || {};
1700
- const computeConfigured = hasComputeConfigured(config);
1701
- const coexistence = deploymentCoexistenceError(config);
1702
- if (coexistence)
1703
- errors.push(coexistence);
1704
- const portOwners = new Map;
1705
- for (const [name, site] of Object.entries(sites)) {
1706
- if (!site) {
1707
- continue;
1708
- }
1709
- const target = resolveSiteDeployTarget(site);
1710
- const kind = resolveSiteKind(site);
1711
- if (kind === "redirect") {
1712
- if (!site.domain)
1713
- errors.push(`Site '${name}' is a redirect site but has no \`domain\` to redirect from.`);
1714
- const to = typeof site.redirect === "string" ? site.redirect : site.redirect?.to;
1715
- if (!to)
1716
- errors.push(`Site '${name}' is a redirect site but has no redirect target (\`redirect\` / \`redirect.to\`).`);
1717
- if (!computeConfigured) {
1718
- errors.push(`Site '${name}' is a redirect site but no \`infrastructure.compute\` is configured to host the gateway that serves the redirect.`);
1838
+ async runRemoteDeploy(options) {
1839
+ const region = this.region;
1840
+ const ssm = new SSMClient(region);
1841
+ if (options.targets.length > 0) {
1842
+ const sendResult = await ssm.sendCommand({
1843
+ InstanceIds: options.targets.map((target) => target.id),
1844
+ DocumentName: "AWS-RunShellScript",
1845
+ Parameters: { commands: this.bashWrap(options.commands) },
1846
+ TimeoutSeconds: options.timeoutSeconds || 600,
1847
+ Comment: options.comment
1848
+ });
1849
+ if (!sendResult.CommandId) {
1850
+ return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
1719
1851
  }
1720
- const serverOnly = [];
1721
- if (site.start)
1722
- serverOnly.push("start");
1723
- if (site.root)
1724
- serverOnly.push("root");
1725
- if (serverOnly.length > 0)
1726
- warnings.push(`Site '${name}' is a redirect site but also sets ${serverOnly.join(", ")}. These are ignored.`);
1727
- continue;
1852
+ return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
1728
1853
  }
1729
- if (target === "server" && !site.start && !site.root) {
1730
- errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
1731
- continue;
1854
+ if (!options.tags || Object.keys(options.tags).length === 0) {
1855
+ return { success: false, instanceCount: 0, perInstance: [], error: "No targets or tags provided for AWS deploy" };
1732
1856
  }
1733
- if (kind === "server-php") {
1734
- if (!computeConfigured) {
1735
- errors.push(`Site '${name}' is a PHP site (type:'${site.type}') but no \`infrastructure.compute\` is configured. Add a server (infrastructure.compute) with PHP provisioning.`);
1736
- }
1737
- if (!site.repository?.url) {
1738
- errors.push(`Site '${name}' is a PHP site (type:'${site.type}') but has no \`repository.url\` to clone. PHP sites deploy via git.`);
1739
- }
1740
- } else if (kind === "server-app") {
1741
- if (!computeConfigured) {
1742
- errors.push(`Site '${name}' deploys to a server (deploy:'server'${site.deploy ? "" : " inferred from `start`"}) but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
1743
- }
1744
- if (typeof site.port === "number") {
1745
- const existing = portOwners.get(site.port);
1746
- if (existing) {
1747
- errors.push(`Sites '${existing}' and '${name}' both use port ${site.port}. Server apps sharing a box must use distinct ports.`);
1748
- } else {
1749
- portOwners.set(site.port, name);
1857
+ const result = await ssm.sendCommandByTags({
1858
+ tags: options.tags,
1859
+ commands: this.bashWrap(options.commands),
1860
+ timeoutSeconds: options.timeoutSeconds || 600,
1861
+ comment: options.comment
1862
+ });
1863
+ return {
1864
+ success: result.success,
1865
+ instanceCount: result.instanceCount,
1866
+ perInstance: result.perInstance.map((item) => ({
1867
+ instanceId: item.instanceId,
1868
+ status: item.status,
1869
+ output: item.output,
1870
+ error: item.error
1871
+ })),
1872
+ error: result.error
1873
+ };
1874
+ }
1875
+ async pollSsmCommand(ssm, commandId, expectedCount) {
1876
+ const pollInterval = 3000;
1877
+ const maxWait = 600000;
1878
+ const startTime = Date.now();
1879
+ const terminalStatuses = new Set(["Success", "Failed", "Cancelled", "TimedOut"]);
1880
+ let lastInvocations = [];
1881
+ while (Date.now() - startTime < maxWait) {
1882
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
1883
+ try {
1884
+ const invocations = await ssm.listCommandInvocations({ CommandId: commandId, Details: true });
1885
+ lastInvocations = invocations;
1886
+ if (lastInvocations.length >= expectedCount && lastInvocations.every((i) => terminalStatuses.has(i.Status || ""))) {
1887
+ break;
1750
1888
  }
1751
- }
1752
- } else if (kind === "server-static") {
1753
- if (!site.root) {
1754
- errors.push(`Site '${name}' is a server static site (deploy:'server', no \`start\`) but has no \`root\` directory to serve.`);
1755
- }
1756
- if (!computeConfigured) {
1757
- errors.push(`Site '${name}' deploys to a server (deploy:'server') but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
1758
- }
1759
- } else {
1760
- if (!site.root) {
1761
- errors.push(`Site '${name}' deploys to a bucket but has no \`root\` directory to upload.`);
1762
- }
1763
- const serverOnly = [];
1764
- if (site.start)
1765
- serverOnly.push("start");
1766
- if (typeof site.port === "number")
1767
- serverOnly.push("port");
1768
- if (site.preStart && site.preStart.length > 0)
1769
- serverOnly.push("preStart");
1770
- if (serverOnly.length > 0) {
1771
- warnings.push(`Site '${name}' deploys to a bucket but sets server-only field(s): ${serverOnly.join(", ")}. These are ignored. Set deploy:'server' to use them.`);
1772
- }
1889
+ } catch {}
1773
1890
  }
1774
- }
1775
- return { errors, warnings };
1776
- }
1777
-
1778
- // src/drivers/shared/rpx-gateway.ts
1779
- var DEFAULT_RPX_CERTS_DIR = "/etc/rpx/certs";
1780
- var DEFAULT_ACME_WEBROOT = "/var/www/acme-challenge";
1781
- function normalizeSiteRedirect(input) {
1782
- if (typeof input === "string")
1783
- return { to: input };
1784
- const out = { to: input.to };
1785
- if (input.status != null)
1786
- out.status = input.status;
1787
- if (input.preservePath != null)
1788
- out.preservePath = input.preservePath;
1789
- return out;
1790
- }
1791
- function resolveRouteAuth(site) {
1792
- const auth = site.auth;
1793
- if (!auth || auth.enabled === false || !auth.password)
1794
- return;
1795
- return {
1796
- username: auth.username || "admin",
1797
- password: auth.password,
1798
- ...auth.realm ? { realm: auth.realm } : {}
1799
- };
1800
- }
1801
- function normalizeRoutePath(path) {
1802
- if (!path || path === "/")
1803
- return;
1804
- let p = `/${path}`.replace(/\/+/g, "/").replace(/\/+$/, "");
1805
- if (!p.startsWith("/"))
1806
- p = `/${p}`;
1807
- return p === "" || p === "/" ? undefined : p;
1808
- }
1809
- function deriveRouteId(to, path) {
1810
- const base = path ? `${to}${path}` : to;
1811
- const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128);
1812
- return cleaned.length > 0 ? cleaned : "rpx";
1813
- }
1814
- function resolveServerAppFrom(port, appBoxes) {
1815
- if (!appBoxes || appBoxes.length === 0)
1816
- return `localhost:${port}`;
1817
- return appBoxes.map((box) => `${box.privateIp ?? box.publicIp}:${port}`);
1891
+ const perInstance = lastInvocations.map((item) => ({
1892
+ instanceId: item.InstanceId,
1893
+ status: item.Status || "Unknown",
1894
+ output: item.StandardOutputContent,
1895
+ error: item.StandardErrorContent
1896
+ }));
1897
+ const success = perInstance.length > 0 && perInstance.every((item) => item.status === "Success");
1898
+ return {
1899
+ success,
1900
+ instanceCount: perInstance.length,
1901
+ perInstance,
1902
+ error: success ? undefined : "One or more SSM command invocations failed"
1903
+ };
1904
+ }
1818
1905
  }
1819
- function buildRpxConfigInternal(sites, options, appBoxes) {
1820
- const wwwRoot = (options.wwwRoot ?? "/var/www").replace(/\/+$/, "");
1821
- const installSlug = options.slug ?? "app";
1822
- const certsDir = options.proxy.certsDir ?? DEFAULT_RPX_CERTS_DIR;
1823
- const loadBalancer = options.proxy.loadBalancer;
1824
- const proxies = [];
1825
- const domains = new Set;
1826
- for (const [name, site] of Object.entries(sites)) {
1827
- if (!site || !site.domain)
1828
- continue;
1829
- const kind = resolveSiteKind(site);
1830
- if (kind === "bucket")
1831
- continue;
1832
- const path = normalizeRoutePath(site.path);
1833
- const id = deriveRouteId(site.domain, path);
1834
- const auth = resolveRouteAuth(site);
1835
- if (kind === "redirect") {
1836
- proxies.push({ to: site.domain, path, redirect: normalizeSiteRedirect(site.redirect), id, ...auth ? { auth } : {} });
1837
- domains.add(site.domain);
1838
- continue;
1906
+
1907
+ // src/drivers/hetzner/driver.ts
1908
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1909
+ import { homedir } from "node:os";
1910
+ import { join as join3 } from "node:path";
1911
+ import { execSync } from "node:child_process";
1912
+
1913
+ // src/drivers/hetzner/client.ts
1914
+ var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
1915
+
1916
+ class HetznerClient {
1917
+ name = "hetzner";
1918
+ apiToken;
1919
+ baseUrl;
1920
+ fetchImpl;
1921
+ constructor(options) {
1922
+ this.apiToken = options.apiToken;
1923
+ this.baseUrl = options.baseUrl ?? DEFAULT_API_URL;
1924
+ this.fetchImpl = options.fetchImpl ?? fetch;
1925
+ }
1926
+ async request(method, path, body) {
1927
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
1928
+ method,
1929
+ headers: {
1930
+ Authorization: `Bearer ${this.apiToken}`,
1931
+ "Content-Type": "application/json"
1932
+ },
1933
+ body: body === undefined ? undefined : JSON.stringify(body)
1934
+ });
1935
+ const text = await response.text();
1936
+ let data;
1937
+ try {
1938
+ data = text ? JSON.parse(text) : {};
1939
+ } catch {
1940
+ if (!response.ok) {
1941
+ const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
1942
+ throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
1943
+ }
1944
+ throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
1839
1945
  }
1840
- if (kind === "server-app") {
1841
- if (typeof site.port !== "number")
1842
- continue;
1843
- const from = resolveServerAppFrom(site.port, appBoxes);
1844
- proxies.push({
1845
- to: site.domain,
1846
- path,
1847
- from,
1848
- id,
1849
- ...auth ? { auth } : {},
1850
- ...Array.isArray(from) && loadBalancer ? { loadBalancer } : {}
1851
- });
1852
- } else {
1853
- proxies.push({
1854
- to: site.domain,
1855
- path,
1856
- static: `${wwwRoot}/${installSlug}-${name}/current`,
1857
- cleanUrls: site.pathRewriteStyle !== "flat",
1858
- spa: site.spa ?? false,
1859
- ...auth ? { auth } : {},
1860
- id
1861
- });
1946
+ if (!response.ok) {
1947
+ const message = data.error?.message || response.statusText || "Hetzner API error";
1948
+ const code = data.error?.code ? ` [${data.error.code}]` : "";
1949
+ throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
1862
1950
  }
1863
- domains.add(site.domain);
1951
+ return data;
1864
1952
  }
1865
- if (options.proxy.autoWww !== false) {
1866
- for (const domain of [...domains]) {
1867
- if (domain.split(".").length !== 2)
1868
- continue;
1869
- const wwwDomain = `www.${domain}`;
1870
- if (domains.has(wwwDomain))
1871
- continue;
1872
- proxies.push({ to: wwwDomain, redirect: { to: `https://${domain}` }, id: deriveRouteId(wwwDomain) });
1873
- domains.add(wwwDomain);
1874
- }
1953
+ async listServers() {
1954
+ const data = await this.request("GET", "/servers");
1955
+ return data.servers;
1875
1956
  }
1876
- proxies.sort((a, b) => {
1877
- if (a.to !== b.to)
1878
- return a.to.localeCompare(b.to);
1879
- return (b.path?.length ?? 0) - (a.path?.length ?? 0);
1880
- });
1881
- const config = {
1882
- proxies,
1883
- productionCerts: { certsDir },
1884
- https: true,
1885
- hostsManagement: false,
1886
- cleanup: { hosts: false, certs: false }
1887
- };
1888
- if (options.proxy.onDemandTls && domains.size > 0) {
1889
- config.onDemandTls = {
1890
- enabled: true,
1891
- allowedSuffixes: [...domains],
1892
- email: options.proxy.onDemandTlsEmail,
1893
- certsDir
1894
- };
1957
+ async getServer(id) {
1958
+ const data = await this.request("GET", `/servers/${id}`);
1959
+ return data.server;
1895
1960
  }
1896
- if (options.proxy.onDemandTls) {
1897
- config.acmeChallengeWebroot = options.proxy.acmeWebroot ?? DEFAULT_ACME_WEBROOT;
1961
+ async createServer(options) {
1962
+ const data = await this.request("POST", "/servers", {
1963
+ name: options.name,
1964
+ server_type: options.serverType,
1965
+ image: options.image,
1966
+ location: options.location,
1967
+ datacenter: options.datacenter,
1968
+ ssh_keys: options.sshKeys,
1969
+ user_data: options.userData,
1970
+ labels: options.labels,
1971
+ firewalls: options.firewalls,
1972
+ networks: options.networks,
1973
+ start_after_create: true
1974
+ });
1975
+ return { server: data.server, action: data.action };
1976
+ }
1977
+ async listNetworks() {
1978
+ const data = await this.request("GET", "/networks");
1979
+ return data.networks;
1980
+ }
1981
+ async createNetwork(options) {
1982
+ const ipRange = options.ipRange ?? "10.0.0.0/16";
1983
+ const data = await this.request("POST", "/networks", {
1984
+ name: options.name,
1985
+ ip_range: ipRange,
1986
+ subnets: [{ type: "cloud", ip_range: ipRange, network_zone: "eu-central" }],
1987
+ labels: options.labels
1988
+ });
1989
+ return data.network;
1990
+ }
1991
+ async deleteNetwork(id) {
1992
+ await this.request("DELETE", `/networks/${id}`);
1993
+ }
1994
+ async listLoadBalancers() {
1995
+ const data = await this.request("GET", "/load_balancers");
1996
+ return data.load_balancers;
1997
+ }
1998
+ async createLoadBalancer(options) {
1999
+ const data = await this.request("POST", "/load_balancers", {
2000
+ name: options.name,
2001
+ load_balancer_type: options.type ?? "lb11",
2002
+ location: options.location,
2003
+ network_zone: options.network ? undefined : options.networkZone ?? "eu-central",
2004
+ network: options.network,
2005
+ labels: options.labels,
2006
+ targets: [{ type: "label_selector", label_selector: { selector: options.labelSelector }, use_private_ip: !!options.network }],
2007
+ services: options.services.map((s) => ({
2008
+ protocol: s.protocol ?? "tcp",
2009
+ listen_port: s.listenPort,
2010
+ destination_port: s.destinationPort,
2011
+ health_check: {
2012
+ protocol: "http",
2013
+ port: 80,
2014
+ interval: 15,
2015
+ timeout: 10,
2016
+ retries: 3,
2017
+ http: { path: "/", status_codes: ["2??", "3??"] }
2018
+ }
2019
+ }))
2020
+ });
2021
+ return data.load_balancer;
2022
+ }
2023
+ async deleteLoadBalancer(id) {
2024
+ await this.request("DELETE", `/load_balancers/${id}`);
2025
+ }
2026
+ async deleteServer(id) {
2027
+ const data = await this.request("DELETE", `/servers/${id}`);
2028
+ return data.action;
2029
+ }
2030
+ async listFirewalls() {
2031
+ const data = await this.request("GET", "/firewalls");
2032
+ return data.firewalls;
2033
+ }
2034
+ async createFirewall(options) {
2035
+ const data = await this.request("POST", "/firewalls", {
2036
+ name: options.name,
2037
+ rules: options.rules,
2038
+ labels: options.labels,
2039
+ apply_to: options.applyTo
2040
+ });
2041
+ return { firewall: data.firewall, actions: data.actions };
2042
+ }
2043
+ async setFirewallRules(firewallId, rules) {
2044
+ const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
2045
+ rules
2046
+ });
2047
+ return data.actions ?? [];
2048
+ }
2049
+ async deleteFirewall(firewallId) {
2050
+ await this.request("DELETE", `/firewalls/${firewallId}`);
2051
+ }
2052
+ async applyFirewallToResources(firewallId, applyTo) {
2053
+ const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
2054
+ apply_to: applyTo
2055
+ });
2056
+ return data.actions;
2057
+ }
2058
+ async listSshKeys() {
2059
+ const data = await this.request("GET", "/ssh_keys");
2060
+ return data.ssh_keys;
2061
+ }
2062
+ async createSshKey(options) {
2063
+ const data = await this.request("POST", "/ssh_keys", {
2064
+ name: options.name,
2065
+ public_key: options.publicKey,
2066
+ labels: options.labels
2067
+ });
2068
+ return data.ssh_key;
2069
+ }
2070
+ async waitForAction(actionId, options) {
2071
+ const pollInterval = options?.pollIntervalMs ?? 2000;
2072
+ const maxWait = options?.maxWaitMs ?? 300000;
2073
+ const start = Date.now();
2074
+ while (Date.now() - start < maxWait) {
2075
+ const data = await this.request("GET", `/actions/${actionId}`);
2076
+ if (data.action.status === "success")
2077
+ return data.action;
2078
+ if (data.action.status === "error") {
2079
+ throw new Error(data.action.error?.message || "Hetzner action failed");
2080
+ }
2081
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
2082
+ }
2083
+ throw new Error(`Timed out waiting for Hetzner action ${actionId}`);
1898
2084
  }
1899
- const cdn = options.proxy.cdn;
1900
- if (cdn?.secret && cdn.frontedHosts.length > 0) {
1901
- config.originGuard = {
1902
- header: cdn.secretHeader ?? "X-Origin-Verify",
1903
- value: cdn.secret,
1904
- hosts: cdn.frontedHosts
1905
- };
2085
+ async waitForServerRunning(serverId, options) {
2086
+ const pollInterval = options?.pollIntervalMs ?? 3000;
2087
+ const maxWait = options?.maxWaitMs ?? 600000;
2088
+ const start = Date.now();
2089
+ while (Date.now() - start < maxWait) {
2090
+ const server = await this.getServer(serverId);
2091
+ if (server.status === "running")
2092
+ return server;
2093
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
2094
+ }
2095
+ throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
1906
2096
  }
1907
- return config;
1908
- }
1909
- function buildRpxConfig(sites, options) {
1910
- return buildRpxConfigInternal(sites, options);
1911
- }
1912
- function buildRpxLbConfig(sites, appBoxes, options) {
1913
- return buildRpxConfigInternal(sites, options, appBoxes);
1914
- }
1915
- function renderRpxLauncher(config) {
1916
- const json = JSON.stringify(config, null, 2);
1917
- return `// Generated by ts-cloud — rpx reverse-proxy gateway.
1918
- // Routes are derived from the \`sites\` model on every \`buddy deploy\`.
1919
- import { startProxies } from '@stacksjs/rpx'
1920
-
1921
- const config = ${json} as const
1922
-
1923
- await startProxies(config as any)
1924
- `;
1925
2097
  }
1926
- var RPX_DIR = "/etc/rpx";
1927
- var RPX_INSTALL_DIR = "/opt/rpx-gateway";
1928
- var RPX_LAUNCHER_PATH = "/etc/rpx/gateway.ts";
1929
- var RPX_SERVICE_NAME = "rpx-gateway.service";
1930
- var RPX_SITES_DIR = "/etc/rpx/sites.d";
1931
- function renderRpxAssembler(sitesDir = RPX_SITES_DIR, defaultCertsDir = DEFAULT_RPX_CERTS_DIR) {
1932
- return `// Generated by ts-cloud — rpx gateway assembler.
1933
- // Merges every app's fragment in ${sitesDir} so independent deploys compose
1934
- // without clobbering each other. Each deploy writes only its own <slug>.json.
1935
- import { startProxies } from '@stacksjs/rpx'
1936
- import { readdirSync, readFileSync } from 'node:fs'
1937
-
1938
- const dir = ${JSON.stringify(sitesDir)}
1939
- const proxies = []
1940
- const seen = new Set()
1941
- const suffixes = new Set()
1942
- const guardHosts = new Set()
1943
- let email
1944
- let certsDir = ${JSON.stringify(defaultCertsDir)}
1945
- let acmeChallengeWebroot
1946
- let guard
1947
- let files = []
1948
- try { files = readdirSync(dir).filter(n => n.endsWith('.json')).sort() } catch {}
1949
- for (const f of files) {
1950
- let frag
1951
- try { frag = JSON.parse(readFileSync(dir + '/' + f, 'utf8')) } catch { continue }
1952
- for (const p of frag.proxies ?? []) {
1953
- const key = p.id || (p.to + (p.path ?? ''))
1954
- if (seen.has(key)) continue
1955
- seen.add(key)
1956
- proxies.push(p)
1957
- }
1958
- for (const s of frag.onDemandTls?.allowedSuffixes ?? []) suffixes.add(s)
1959
- email ??= frag.onDemandTls?.email
1960
- if (frag.productionCerts?.certsDir) certsDir = frag.productionCerts.certsDir
1961
- acmeChallengeWebroot ??= frag.acmeChallengeWebroot
1962
- if (frag.originGuard) {
1963
- guard ??= { header: frag.originGuard.header, value: frag.originGuard.value }
1964
- for (const h of frag.originGuard.hosts ?? []) guardHosts.add(h)
2098
+ function resolveHetznerApiToken(configToken) {
2099
+ const token = configToken || process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN;
2100
+ if (!token) {
2101
+ throw new Error("Hetzner API token required. Set hetzner.apiToken in cloud.config.ts or HCLOUD_TOKEN / HETZNER_API_TOKEN.");
1965
2102
  }
2103
+ return token;
1966
2104
  }
1967
- const config = {
1968
- proxies,
1969
- productionCerts: { certsDir },
1970
- https: true,
1971
- hostsManagement: false,
1972
- cleanup: { hosts: false, certs: false },
1973
- ...(suffixes.size > 0 ? { onDemandTls: { enabled: true, allowedSuffixes: [...suffixes], email, certsDir } } : {}),
1974
- ...(acmeChallengeWebroot ? { acmeChallengeWebroot } : {}),
1975
- ...(guard ? { originGuard: { header: guard.header, value: guard.value, hosts: [...guardHosts] } } : {}),
2105
+ function normalizeSshPublicKey(publicKey) {
2106
+ const [type, body] = publicKey.trim().split(/\s+/);
2107
+ return body ? `${type} ${body}` : type;
1976
2108
  }
1977
2109
 
1978
- await startProxies(config)
1979
- `;
1980
- }
1981
- function writeFileHeredoc(path, content, delimiter) {
1982
- return [
1983
- `cat > ${path} <<'${delimiter}'`,
1984
- content,
1985
- delimiter
1986
- ];
1987
- }
1988
- function certDomainsForConfig(config) {
1989
- const seen = new Set;
1990
- for (const r of config.proxies) {
1991
- const host = r.to;
1992
- if (!host || host.startsWith("*") || host.includes(":") || !host.includes("."))
1993
- continue;
1994
- seen.add(host);
1995
- }
1996
- return [...seen];
1997
- }
1998
- function buildCertManagementCommands(options) {
1999
- const { config, proxy } = options;
2000
- const webroot = config.acmeChallengeWebroot;
2001
- const domains = certDomainsForConfig(config);
2002
- if (!proxy.onDemandTls || !webroot || domains.length === 0)
2003
- return [];
2004
- const bunBin = options.bunBin ?? "/usr/local/bin/bun";
2005
- const version = proxy.version ?? "latest";
2006
- const certsDir = config.productionCerts.certsDir;
2007
- const email = proxy.onDemandTlsEmail ?? `webmaster@${domains[0]}`;
2008
- const tlsxCli = `${bunBin} ${RPX_INSTALL_DIR}/node_modules/@stacksjs/tlsx/dist/bin/cli.js`;
2009
- const csv = domains.join(",");
2010
- const spaced = domains.join(" ");
2011
- const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
2012
- const renewScriptPath = `${RPX_DIR}/renew-certs-${slug}.sh`;
2013
- const renewServiceName = `rpx-cert-renew-${slug}.service`;
2014
- const renewTimerName = `rpx-cert-renew-${slug}.timer`;
2015
- const renewScript = [
2016
- "#!/bin/sh",
2017
- "# Generated by ts-cloud — issue/renew rpx gateway TLS certs via tlsx http-01.",
2018
- "# The running gateway serves the challenge from $WEBROOT on :80, so this needs",
2019
- "# no downtime and no DNS credentials. Reloads the gateway only if a cert changed.",
2020
- "set -u",
2021
- `CERTS='${certsDir}'`,
2022
- `WEBROOT='${webroot}'`,
2023
- `EMAIL='${email}'`,
2024
- `TLSX="${tlsxCli}"`,
2025
- `DOMAINS='${csv}'`,
2026
- 'before=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
2027
- `for d in ${spaced}; do`,
2028
- ' [ -s "$CERTS/$d.crt" ] || $TLSX acme:issue -d "$d" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --prod --email "$EMAIL" || echo "issue $d failed (non-fatal)"',
2029
- "done",
2030
- '$TLSX acme:renew --domains "$DOMAINS" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --days 30 --prod --email "$EMAIL" || echo "renew: some domains failed (non-fatal)"',
2031
- 'rm -f "$CERTS"/*.chain.crt',
2032
- 'after=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
2033
- `[ "$before" = "$after" ] || systemctl restart ${RPX_SERVICE_NAME}`
2034
- ].join(`
2035
- `);
2036
- const renewService = [
2037
- "[Unit]",
2038
- `Description=Issue/renew rpx gateway TLS certs for ${slug} (tlsx http-01)`,
2039
- `After=network-online.target ${RPX_SERVICE_NAME}`,
2040
- "Wants=network-online.target",
2041
- "",
2042
- "[Service]",
2043
- "Type=oneshot",
2044
- `ExecStart=${renewScriptPath}`
2045
- ].join(`
2046
- `);
2047
- const renewTimer = [
2048
- "[Unit]",
2049
- `Description=Daily rpx gateway TLS cert issuance/renewal for ${slug}`,
2050
- "",
2051
- "[Timer]",
2052
- "OnCalendar=*-*-* 03:30:00",
2053
- "RandomizedDelaySec=1h",
2054
- "Persistent=true",
2055
- "",
2056
- "[Install]",
2057
- "WantedBy=timers.target"
2058
- ].join(`
2110
+ // src/drivers/hetzner/cloud-init.ts
2111
+ function wrapCloudInitUserData(bootstrapScript) {
2112
+ const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
2113
+ const indented = bootstrapScript.split(`
2114
+ `).map((line) => ` ${line}`).join(`
2059
2115
  `);
2060
- return [
2061
- `mkdir -p ${webroot}`,
2062
- `(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/tlsx@${version}) || true`,
2063
- ...writeFileHeredoc(renewScriptPath, renewScript, "TS_CLOUD_RENEW_EOF"),
2064
- `chmod +x ${renewScriptPath}`,
2065
- ...writeFileHeredoc(`/etc/systemd/system/${renewServiceName}`, renewService, "TS_CLOUD_RENEW_SVC_EOF"),
2066
- ...writeFileHeredoc(`/etc/systemd/system/${renewTimerName}`, renewTimer, "TS_CLOUD_RENEW_TIMER_EOF"),
2067
- "systemctl daemon-reload",
2068
- `systemctl enable --now ${renewTimerName} || true`,
2069
- `${renewScriptPath} || true`
2070
- ];
2071
- }
2072
- function buildRpxProvisionScript(options) {
2073
- const { config, proxy } = options;
2074
- const bunBin = options.bunBin ?? "/usr/local/bin/bun";
2075
- const version = proxy.version ?? "latest";
2076
- const certsDir = config.productionCerts.certsDir;
2077
- const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
2078
- const fragment = JSON.stringify({ slug, ...config }, null, 2);
2079
- const assembler = renderRpxAssembler(RPX_SITES_DIR, certsDir);
2080
- const upstreamTimeout = proxy.upstreamTimeout ?? 60;
2081
- const poolEnv = [`Environment=RPX_UPSTREAM_TIMEOUT=${upstreamTimeout}`];
2082
- if (typeof proxy.maxUpstreamConns === "number")
2083
- poolEnv.push(`Environment=RPX_MAX_UPSTREAM_CONNS=${proxy.maxUpstreamConns}`);
2084
- return [
2085
- "set -euo pipefail",
2086
- `mkdir -p ${RPX_DIR} ${RPX_SITES_DIR} ${certsDir} ${RPX_INSTALL_DIR}`,
2087
- `rm -rf ${RPX_INSTALL_DIR}/node_modules ${RPX_INSTALL_DIR}/bun.lock ${RPX_INSTALL_DIR}/package.json`,
2088
- `(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/rpx@${version})`,
2089
- `ln -sfn ${RPX_INSTALL_DIR}/node_modules ${RPX_DIR}/node_modules`,
2090
- ...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF"),
2091
- ...writeFileHeredoc(RPX_LAUNCHER_PATH, assembler, "TS_CLOUD_RPX_EOF"),
2092
- ...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
2093
- "[Unit]",
2094
- "Description=rpx reverse-proxy gateway (managed by ts-cloud)",
2095
- "After=network.target network-online.target",
2096
- "Wants=network-online.target",
2097
- "",
2098
- "[Service]",
2099
- "Type=simple",
2100
- `ExecStart=${bunBin} ${RPX_LAUNCHER_PATH}`,
2101
- `WorkingDirectory=${RPX_INSTALL_DIR}`,
2102
- `Environment=BUN_INSTALL=/root/.bun`,
2103
- ...poolEnv,
2104
- "Restart=always",
2105
- "RestartSec=5",
2106
- "LimitNOFILE=1048576",
2107
- "AmbientCapabilities=CAP_NET_BIND_SERVICE",
2108
- "",
2109
- "[Install]",
2110
- "WantedBy=multi-user.target"
2111
- ].join(`
2112
- `), "TS_CLOUD_RPX_UNIT_EOF"),
2113
- "systemctl daemon-reload",
2114
- "systemctl disable --now bun-gateway.service 2>/dev/null || true",
2115
- "systemctl disable --now ts-cloud-nginx.service 2>/dev/null || true",
2116
- `systemctl enable ${RPX_SERVICE_NAME}`,
2117
- `systemctl restart ${RPX_SERVICE_NAME}`,
2118
- ...buildCertManagementCommands(options)
2119
- ];
2116
+ return `#cloud-config
2117
+ write_files:
2118
+ - path: ${scriptPath}
2119
+ permissions: '0755'
2120
+ owner: root:root
2121
+ content: |
2122
+ ${indented}
2123
+ runcmd:
2124
+ - [ bash, ${scriptPath} ]
2125
+ `;
2120
2126
  }
2121
2127
 
2122
2128
  // src/drivers/shared/fleet.ts
@@ -2435,7 +2441,7 @@ class HetznerDriver {
2435
2441
  versions: compute.php?.versions,
2436
2442
  default: compute.php?.default,
2437
2443
  extensions: compute.php?.extensions,
2438
- installNginx: compute.webServer !== "rpx",
2444
+ installNginx: !usesRpxProxy(compute),
2439
2445
  optimizeForProduction: compute.php?.optimizeForProduction,
2440
2446
  ini: compute.php?.ini
2441
2447
  });
@@ -3019,11 +3025,20 @@ ${out}`);
3019
3025
  "-o",
3020
3026
  "LogLevel=ERROR"
3021
3027
  ];
3028
+ static SSH_KEEPALIVE_OPTS = [
3029
+ "-o",
3030
+ "ConnectTimeout=30",
3031
+ "-o",
3032
+ "ServerAliveInterval=15",
3033
+ "-o",
3034
+ "ServerAliveCountMax=4"
3035
+ ];
3022
3036
  sshBaseArgs(host, extra = []) {
3023
3037
  return [
3024
3038
  "-i",
3025
3039
  this.sshPrivateKeyPath,
3026
3040
  ...HetznerDriver.SSH_HOST_KEY_OPTS,
3041
+ ...HetznerDriver.SSH_KEEPALIVE_OPTS,
3027
3042
  "-o",
3028
3043
  "BatchMode=yes",
3029
3044
  ...extra,
@@ -3031,16 +3046,27 @@ ${out}`);
3031
3046
  ];
3032
3047
  }
3033
3048
  scpToHost(host, localPath, remotePath) {
3034
- execSync([
3049
+ const cmd = [
3035
3050
  "scp",
3036
3051
  "-i",
3037
3052
  this.sshPrivateKeyPath,
3038
3053
  ...HetznerDriver.SSH_HOST_KEY_OPTS,
3054
+ ...HetznerDriver.SSH_KEEPALIVE_OPTS,
3039
3055
  "-o",
3040
3056
  "BatchMode=yes",
3041
3057
  localPath,
3042
3058
  `${this.sshUser}@${host}:${remotePath}`
3043
- ].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" "), { stdio: "pipe", maxBuffer: SSH_MAX_BUFFER });
3059
+ ].map((arg) => `"${arg.replace(/"/g, "\\\"")}"`).join(" ");
3060
+ const attempts = 3;
3061
+ for (let attempt = 1;attempt <= attempts; attempt++) {
3062
+ try {
3063
+ execSync(cmd, { stdio: "pipe", maxBuffer: SSH_MAX_BUFFER });
3064
+ return;
3065
+ } catch (error) {
3066
+ if (attempt === attempts)
3067
+ throw new Error(formatSshFailure(error));
3068
+ }
3069
+ }
3044
3070
  }
3045
3071
  sshExec(host, script) {
3046
3072
  const escaped = script.replace(/'/g, `'\\''`);
@@ -4398,7 +4424,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4398
4424
  appBase: appBase2,
4399
4425
  defaultPhpVersion: phpVersion
4400
4426
  });
4401
- const useNginx = compute2?.webServer !== "rpx";
4427
+ const useNginx = !usesRpxProxy(compute2);
4402
4428
  const sslProvider = resolveSslProvider(site);
4403
4429
  const customCert = sslProvider === "custom" && site.ssl?.certPath && site.ssl?.keyPath ? { certPath: site.ssl.certPath, keyPath: site.ssl.keyPath } : undefined;
4404
4430
  const poolScript = site.isolation ? buildPhpFpmPoolScript({ siteName, appBase: appBase2 }) : [];
@@ -4480,7 +4506,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4480
4506
  healthCheckPath: site.healthCheck?.path
4481
4507
  });
4482
4508
  const compute = config.infrastructure?.compute;
4483
- const wantsNginxStatic = kind === "server-static" && compute?.webServer !== "rpx" && !!site.domain;
4509
+ const wantsNginxStatic = kind === "server-static" && !usesRpxProxy(compute) && !!site.domain;
4484
4510
  const staticVhost = wantsNginxStatic ? buildNginxVhostScript({
4485
4511
  siteName,
4486
4512
  domain: site.domain,
@@ -4741,4 +4767,4 @@ function buildCloudFrontOriginConfig(options) {
4741
4767
  CustomErrorResponses: { Quantity: 0 }
4742
4768
  };
4743
4769
  }
4744
- export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, HetznerClient, resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, DEFAULT_RPX_CERTS_DIR, normalizeRoutePath, deriveRouteId, buildRpxConfig, buildRpxLbConfig, renderRpxLauncher, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, buildRpxProvisionScript, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, UBUNTU_2404_AMI_PARAM, buildBoxUserData, HetznerBoxProvisioner, AwsBoxProvisioner, createBoxProvisioner, releasePaths, buildRollbackScript, resolveExecStart, buildSiteDeployScript, buildStaticSiteDeployScript, buildAwsArtifactFetch, buildLocalArtifactFetch, MANAGEMENT_DASHBOARD_SITE, DASHBOARD_CREDENTIALS_FILE, resolveDashboardAuth, resolveUiSource, ensureManagementDashboard, buildManagementDashboardArtifact, resolveSiteFramework, deploySiteRelease, deployAllComputeSites, reloadRpxGateway, MANAGED_CACHE_POLICY_OPTIMIZED, MANAGED_CACHE_POLICY_DISABLED, MANAGED_ORIGIN_REQUEST_POLICY_ALL_VIEWER, buildCloudFrontOriginConfig };
4770
+ export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, DEFAULT_RPX_CERTS_DIR, normalizeRoutePath, deriveRouteId, buildRpxConfig, buildRpxLbConfig, renderRpxLauncher, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, buildRpxProvisionScript, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, HetznerClient, resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, UBUNTU_2404_AMI_PARAM, buildBoxUserData, HetznerBoxProvisioner, AwsBoxProvisioner, createBoxProvisioner, releasePaths, buildRollbackScript, resolveExecStart, buildSiteDeployScript, buildStaticSiteDeployScript, buildAwsArtifactFetch, buildLocalArtifactFetch, MANAGEMENT_DASHBOARD_SITE, DASHBOARD_CREDENTIALS_FILE, resolveDashboardAuth, resolveUiSource, ensureManagementDashboard, buildManagementDashboardArtifact, resolveSiteFramework, deploySiteRelease, deployAllComputeSites, reloadRpxGateway, MANAGED_CACHE_POLICY_OPTIMIZED, MANAGED_CACHE_POLICY_DISABLED, MANAGED_ORIGIN_REQUEST_POLICY_ALL_VIEWER, buildCloudFrontOriginConfig };