@stacksjs/ts-cloud 0.7.17 → 0.7.19

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.
Files changed (60) hide show
  1. package/dist/bin/cli.js +747 -621
  2. package/dist/{chunk-pr3g0b0t.js → chunk-nt2hrnva.js} +744 -52
  3. package/dist/{chunk-qergre5a.js → chunk-xgnxz5dz.js} +828 -758
  4. package/dist/deploy/dashboard-auth.d.ts +82 -0
  5. package/dist/deploy/dashboard-auth.test.d.ts +1 -0
  6. package/dist/deploy/dashboard-guard.d.ts +40 -0
  7. package/dist/deploy/dashboard-login-page.d.ts +16 -0
  8. package/dist/deploy/dashboard-pages.test.d.ts +1 -0
  9. package/dist/deploy/dashboard-policy.d.ts +37 -0
  10. package/dist/deploy/dashboard-policy.test.d.ts +1 -0
  11. package/dist/deploy/dashboard-scope.d.ts +24 -0
  12. package/dist/deploy/dashboard-scope.test.d.ts +1 -0
  13. package/dist/deploy/dashboard-session.d.ts +52 -0
  14. package/dist/deploy/dashboard-users.d.ts +60 -0
  15. package/dist/deploy/index.js +2 -2
  16. package/dist/deploy/local-dashboard-server.d.ts +1 -0
  17. package/dist/drivers/hetzner/client.d.ts +9 -1
  18. package/dist/drivers/hetzner/config.d.ts +83 -0
  19. package/dist/drivers/hetzner/config.test.d.ts +1 -0
  20. package/dist/drivers/hetzner/factory-wiring.test.d.ts +1 -0
  21. package/dist/drivers/index.js +1 -1
  22. package/dist/drivers/shared/rpx-gateway.d.ts +29 -2
  23. package/dist/index.js +2 -2
  24. package/dist/ui/index.html +4 -4
  25. package/dist/ui/server/actions.html +4 -4
  26. package/dist/ui/server/activity.html +1 -1
  27. package/dist/ui/server/backups.html +4 -4
  28. package/dist/ui/server/database.html +4 -4
  29. package/dist/ui/server/deployments.html +4 -4
  30. package/dist/ui/server/diagnostics.html +1 -1
  31. package/dist/ui/server/firewall.html +4 -4
  32. package/dist/ui/server/logs.html +4 -4
  33. package/dist/ui/server/metrics.html +1 -1
  34. package/dist/ui/server/security.html +1 -1
  35. package/dist/ui/server/services.html +4 -4
  36. package/dist/ui/server/sites.html +4 -4
  37. package/dist/ui/server/ssh-keys.html +4 -4
  38. package/dist/ui/server/team.html +1214 -0
  39. package/dist/ui/server/terminal.html +4 -4
  40. package/dist/ui/server/workers.html +4 -4
  41. package/dist/ui/serverless/alarms.html +4 -4
  42. package/dist/ui/serverless/assets.html +4 -4
  43. package/dist/ui/serverless/cost.html +1 -1
  44. package/dist/ui/serverless/data.html +4 -4
  45. package/dist/ui/serverless/deployments.html +4 -4
  46. package/dist/ui/serverless/firewall.html +1 -1
  47. package/dist/ui/serverless/functions.html +4 -4
  48. package/dist/ui/serverless/logs.html +4 -4
  49. package/dist/ui/serverless/metrics.html +1 -1
  50. package/dist/ui/serverless/queues.html +4 -4
  51. package/dist/ui/serverless/scheduler.html +4 -4
  52. package/dist/ui/serverless/secrets.html +4 -4
  53. package/dist/ui/serverless/traces.html +4 -4
  54. package/dist/ui/serverless.html +4 -4
  55. package/dist/ui-src/pages/partials/head.stx +7 -0
  56. package/dist/ui-src/pages/partials/nav.stx +43 -6
  57. package/dist/ui-src/pages/server/firewall.stx +1 -1
  58. package/dist/ui-src/pages/server/sites.stx +42 -20
  59. package/dist/ui-src/pages/server/team.stx +171 -0
  60. package/package.json +3 -3
@@ -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({
@@ -1362,761 +1829,360 @@ class AwsDriver {
1362
1829
  });
1363
1830
  }
1364
1831
  }
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;
1832
+ return targets;
1602
1833
  }
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");
1834
+ bashWrap(commands) {
1835
+ return ["bash <<'TS_CLOUD_BASH_EOF'", commands.join(`
1836
+ `), "TS_CLOUD_BASH_EOF"];
1837
+ }
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" };
1613
1851
  }
1614
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
1852
+ return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
1615
1853
  }
1616
- throw new Error(`Timed out waiting for Hetzner action ${actionId}`);
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" };
1856
+ }
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
+ };
1617
1874
  }
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;
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) {
1626
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;
1888
+ }
1889
+ } catch {}
1627
1890
  }
1628
- throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
1629
- }
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.");
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
+ };
1635
1904
  }
1636
- return token;
1637
- }
1638
- function normalizeSshPublicKey(publicKey) {
1639
- const [type, body] = publicKey.trim().split(/\s+/);
1640
- return body ? `${type} ${body}` : type;
1641
1905
  }
1642
1906
 
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
- }
1907
+ // src/drivers/hetzner/driver.ts
1908
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1909
+ import { execSync } from "node:child_process";
1660
1910
 
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;
1911
+ // src/drivers/hetzner/config.ts
1912
+ import { homedir } from "node:os";
1913
+ import { join as join2 } from "node:path";
1914
+ var HETZNER_DEFAULTS = {
1915
+ location: "fsn1",
1916
+ image: "ubuntu-24.04",
1917
+ sshUser: "root",
1918
+ sshPrivateKeyPath: "~/.ssh/id_ed25519"
1919
+ };
1920
+ function expandHome(path) {
1921
+ return path.startsWith("~/") ? join2(homedir(), path.slice(2)) : path;
1695
1922
  }
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.`);
1719
- }
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;
1728
- }
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;
1732
- }
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);
1750
- }
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
- }
1773
- }
1923
+ function env(...names) {
1924
+ for (const name of names) {
1925
+ const value = process.env[name]?.trim();
1926
+ if (value)
1927
+ return value;
1774
1928
  }
1775
- return { errors, warnings };
1929
+ return;
1776
1930
  }
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;
1931
+ function first(...values) {
1932
+ for (const value of values) {
1933
+ const trimmed = value?.trim();
1934
+ if (trimmed)
1935
+ return trimmed;
1936
+ }
1937
+ return;
1790
1938
  }
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
- };
1939
+ function resolveHetznerApiToken(explicit, config) {
1940
+ return first(explicit, config?.hetzner?.apiToken, env("HCLOUD_TOKEN", "HETZNER_API_TOKEN"));
1800
1941
  }
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;
1942
+ function resolveHetznerLocation(config, explicit) {
1943
+ return first(explicit, config?.hetzner?.location, env("HCLOUD_LOCATION", "HETZNER_LOCATION")) ?? HETZNER_DEFAULTS.location;
1808
1944
  }
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";
1945
+ function resolveHetznerImage(config, explicit) {
1946
+ const compute = config?.infrastructure?.compute;
1947
+ return first(explicit, compute?.image, config?.hetzner?.image, env("HCLOUD_IMAGE", "HETZNER_IMAGE")) ?? HETZNER_DEFAULTS.image;
1813
1948
  }
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}`);
1949
+ function resolveHetznerSshUser(config, explicit) {
1950
+ return first(explicit, config?.hetzner?.sshUser, env("HCLOUD_SSH_USER", "HETZNER_SSH_USER")) ?? HETZNER_DEFAULTS.sshUser;
1818
1951
  }
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;
1952
+ function resolveHetznerSshPrivateKeyPath(config, explicit) {
1953
+ return expandHome(first(explicit, config?.hetzner?.sshPrivateKeyPath, env("HCLOUD_SSH_KEY", "HETZNER_SSH_KEY")) ?? HETZNER_DEFAULTS.sshPrivateKeyPath);
1954
+ }
1955
+ function resolveHetznerSshPublicKeyPath(config, explicit, privateKeyPath) {
1956
+ const explicitPath = first(explicit, config?.hetzner?.sshPublicKeyPath, env("HCLOUD_SSH_PUBLIC_KEY", "HETZNER_SSH_PUBLIC_KEY"));
1957
+ if (explicitPath)
1958
+ return expandHome(explicitPath);
1959
+ return `${privateKeyPath ?? resolveHetznerSshPrivateKeyPath(config)}.pub`;
1960
+ }
1961
+ function resolveHetznerSettings(config, overrides = {}) {
1962
+ const sshPrivateKeyPath = resolveHetznerSshPrivateKeyPath(config, overrides.sshPrivateKeyPath);
1963
+ return {
1964
+ apiToken: resolveHetznerApiToken(overrides.apiToken, config),
1965
+ location: resolveHetznerLocation(config, overrides.location),
1966
+ image: resolveHetznerImage(config, overrides.image),
1967
+ sshUser: resolveHetznerSshUser(config, overrides.sshUser),
1968
+ sshPrivateKeyPath,
1969
+ sshPublicKeyPath: resolveHetznerSshPublicKeyPath(config, overrides.sshPublicKeyPath, sshPrivateKeyPath)
1970
+ };
1971
+ }
1972
+
1973
+ // src/drivers/hetzner/client.ts
1974
+ var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
1975
+
1976
+ class HetznerClient {
1977
+ name = "hetzner";
1978
+ apiToken;
1979
+ baseUrl;
1980
+ fetchImpl;
1981
+ constructor(options) {
1982
+ this.apiToken = options.apiToken;
1983
+ this.baseUrl = options.baseUrl ?? DEFAULT_API_URL;
1984
+ this.fetchImpl = options.fetchImpl ?? fetch;
1985
+ }
1986
+ async request(method, path, body) {
1987
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
1988
+ method,
1989
+ headers: {
1990
+ Authorization: `Bearer ${this.apiToken}`,
1991
+ "Content-Type": "application/json"
1992
+ },
1993
+ body: body === undefined ? undefined : JSON.stringify(body)
1994
+ });
1995
+ const text = await response.text();
1996
+ let data;
1997
+ try {
1998
+ data = text ? JSON.parse(text) : {};
1999
+ } catch {
2000
+ if (!response.ok) {
2001
+ const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
2002
+ throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
2003
+ }
2004
+ throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
1839
2005
  }
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
- });
2006
+ if (!response.ok) {
2007
+ const message = data.error?.message || response.statusText || "Hetzner API error";
2008
+ const code = data.error?.code ? ` [${data.error.code}]` : "";
2009
+ throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
1862
2010
  }
1863
- domains.add(site.domain);
2011
+ return data;
1864
2012
  }
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
- }
2013
+ async listServers() {
2014
+ const data = await this.request("GET", "/servers");
2015
+ return data.servers;
1875
2016
  }
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
- };
2017
+ async getServer(id) {
2018
+ const data = await this.request("GET", `/servers/${id}`);
2019
+ return data.server;
1895
2020
  }
1896
- if (options.proxy.onDemandTls) {
1897
- config.acmeChallengeWebroot = options.proxy.acmeWebroot ?? DEFAULT_ACME_WEBROOT;
2021
+ async createServer(options) {
2022
+ const data = await this.request("POST", "/servers", {
2023
+ name: options.name,
2024
+ server_type: options.serverType,
2025
+ image: options.image,
2026
+ location: options.location,
2027
+ datacenter: options.datacenter,
2028
+ ssh_keys: options.sshKeys,
2029
+ user_data: options.userData,
2030
+ labels: options.labels,
2031
+ firewalls: options.firewalls,
2032
+ networks: options.networks,
2033
+ start_after_create: true
2034
+ });
2035
+ return { server: data.server, action: data.action };
2036
+ }
2037
+ async listNetworks() {
2038
+ const data = await this.request("GET", "/networks");
2039
+ return data.networks;
2040
+ }
2041
+ async createNetwork(options) {
2042
+ const ipRange = options.ipRange ?? "10.0.0.0/16";
2043
+ const data = await this.request("POST", "/networks", {
2044
+ name: options.name,
2045
+ ip_range: ipRange,
2046
+ subnets: [{ type: "cloud", ip_range: ipRange, network_zone: "eu-central" }],
2047
+ labels: options.labels
2048
+ });
2049
+ return data.network;
2050
+ }
2051
+ async deleteNetwork(id) {
2052
+ await this.request("DELETE", `/networks/${id}`);
2053
+ }
2054
+ async listLoadBalancers() {
2055
+ const data = await this.request("GET", "/load_balancers");
2056
+ return data.load_balancers;
2057
+ }
2058
+ async createLoadBalancer(options) {
2059
+ const data = await this.request("POST", "/load_balancers", {
2060
+ name: options.name,
2061
+ load_balancer_type: options.type ?? "lb11",
2062
+ location: options.location,
2063
+ network_zone: options.network ? undefined : options.networkZone ?? "eu-central",
2064
+ network: options.network,
2065
+ labels: options.labels,
2066
+ targets: [{ type: "label_selector", label_selector: { selector: options.labelSelector }, use_private_ip: !!options.network }],
2067
+ services: options.services.map((s) => ({
2068
+ protocol: s.protocol ?? "tcp",
2069
+ listen_port: s.listenPort,
2070
+ destination_port: s.destinationPort,
2071
+ health_check: {
2072
+ protocol: "http",
2073
+ port: 80,
2074
+ interval: 15,
2075
+ timeout: 10,
2076
+ retries: 3,
2077
+ http: { path: "/", status_codes: ["2??", "3??"] }
2078
+ }
2079
+ }))
2080
+ });
2081
+ return data.load_balancer;
2082
+ }
2083
+ async deleteLoadBalancer(id) {
2084
+ await this.request("DELETE", `/load_balancers/${id}`);
2085
+ }
2086
+ async deleteServer(id) {
2087
+ const data = await this.request("DELETE", `/servers/${id}`);
2088
+ return data.action;
2089
+ }
2090
+ async listFirewalls() {
2091
+ const data = await this.request("GET", "/firewalls");
2092
+ return data.firewalls;
2093
+ }
2094
+ async createFirewall(options) {
2095
+ const data = await this.request("POST", "/firewalls", {
2096
+ name: options.name,
2097
+ rules: options.rules,
2098
+ labels: options.labels,
2099
+ apply_to: options.applyTo
2100
+ });
2101
+ return { firewall: data.firewall, actions: data.actions };
2102
+ }
2103
+ async setFirewallRules(firewallId, rules) {
2104
+ const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
2105
+ rules
2106
+ });
2107
+ return data.actions ?? [];
2108
+ }
2109
+ async deleteFirewall(firewallId) {
2110
+ await this.request("DELETE", `/firewalls/${firewallId}`);
2111
+ }
2112
+ async applyFirewallToResources(firewallId, applyTo) {
2113
+ const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
2114
+ apply_to: applyTo
2115
+ });
2116
+ return data.actions;
2117
+ }
2118
+ async listSshKeys() {
2119
+ const data = await this.request("GET", "/ssh_keys");
2120
+ return data.ssh_keys;
2121
+ }
2122
+ async createSshKey(options) {
2123
+ const data = await this.request("POST", "/ssh_keys", {
2124
+ name: options.name,
2125
+ public_key: options.publicKey,
2126
+ labels: options.labels
2127
+ });
2128
+ return data.ssh_key;
2129
+ }
2130
+ async waitForAction(actionId, options) {
2131
+ const pollInterval = options?.pollIntervalMs ?? 2000;
2132
+ const maxWait = options?.maxWaitMs ?? 300000;
2133
+ const start = Date.now();
2134
+ while (Date.now() - start < maxWait) {
2135
+ const data = await this.request("GET", `/actions/${actionId}`);
2136
+ if (data.action.status === "success")
2137
+ return data.action;
2138
+ if (data.action.status === "error") {
2139
+ throw new Error(data.action.error?.message || "Hetzner action failed");
2140
+ }
2141
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
2142
+ }
2143
+ throw new Error(`Timed out waiting for Hetzner action ${actionId}`);
1898
2144
  }
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
- };
2145
+ async waitForServerRunning(serverId, options) {
2146
+ const pollInterval = options?.pollIntervalMs ?? 3000;
2147
+ const maxWait = options?.maxWaitMs ?? 600000;
2148
+ const start = Date.now();
2149
+ while (Date.now() - start < maxWait) {
2150
+ const server = await this.getServer(serverId);
2151
+ if (server.status === "running")
2152
+ return server;
2153
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
2154
+ }
2155
+ throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
1906
2156
  }
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
2157
  }
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)
2158
+ function resolveHetznerApiToken2(configToken, config) {
2159
+ const token = resolveHetznerApiToken(configToken, config);
2160
+ if (!token) {
2161
+ throw new Error("Hetzner API token required. Set hetzner.apiToken in cloud.config.ts or HCLOUD_TOKEN / HETZNER_API_TOKEN.");
1965
2162
  }
2163
+ return token;
1966
2164
  }
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] } } : {}),
2165
+ function normalizeSshPublicKey(publicKey) {
2166
+ const [type, body] = publicKey.trim().split(/\s+/);
2167
+ return body ? `${type} ${body}` : type;
1976
2168
  }
1977
2169
 
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(`
2170
+ // src/drivers/hetzner/cloud-init.ts
2171
+ function wrapCloudInitUserData(bootstrapScript) {
2172
+ const scriptPath = "/var/lib/cloud/ts-cloud-bootstrap.sh";
2173
+ const indented = bootstrapScript.split(`
2174
+ `).map((line) => ` ${line}`).join(`
2059
2175
  `);
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
- ];
2176
+ return `#cloud-config
2177
+ write_files:
2178
+ - path: ${scriptPath}
2179
+ permissions: '0755'
2180
+ owner: root:root
2181
+ content: |
2182
+ ${indented}
2183
+ runcmd:
2184
+ - [ bash, ${scriptPath} ]
2185
+ `;
2120
2186
  }
2121
2187
 
2122
2188
  // src/drivers/shared/fleet.ts
@@ -2132,21 +2198,21 @@ function resolveFleetTopology(compute = {}) {
2132
2198
  };
2133
2199
  }
2134
2200
  function buildFleetServicesEnv(servicesPrivateIp, database) {
2135
- const env = {
2201
+ const env2 = {
2136
2202
  REDIS_HOST: servicesPrivateIp,
2137
2203
  MEILISEARCH_HOST: `http://${servicesPrivateIp}:7700`
2138
2204
  };
2139
2205
  if (database?.name) {
2140
- env.DB_CONNECTION = database.engine === "postgres" ? "pgsql" : "mysql";
2141
- env.DB_HOST = servicesPrivateIp;
2142
- env.DB_PORT = String(database.port ?? (database.engine === "postgres" ? 5432 : 3306));
2143
- env.DB_DATABASE = database.name;
2206
+ env2.DB_CONNECTION = database.engine === "postgres" ? "pgsql" : "mysql";
2207
+ env2.DB_HOST = servicesPrivateIp;
2208
+ env2.DB_PORT = String(database.port ?? (database.engine === "postgres" ? 5432 : 3306));
2209
+ env2.DB_DATABASE = database.name;
2144
2210
  if (database.username)
2145
- env.DB_USERNAME = database.username;
2211
+ env2.DB_USERNAME = database.username;
2146
2212
  if (database.password)
2147
- env.DB_PASSWORD = database.password;
2213
+ env2.DB_PASSWORD = database.password;
2148
2214
  }
2149
- return env;
2215
+ return env2;
2150
2216
  }
2151
2217
 
2152
2218
  // src/drivers/hetzner/firewall-rules.ts
@@ -2200,10 +2266,10 @@ function matchesTsCloudLabels(labels, slug, environment, role = "app") {
2200
2266
 
2201
2267
  // src/drivers/hetzner/state.ts
2202
2268
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2203
- import { join as join2 } from "node:path";
2269
+ import { join as join3 } from "node:path";
2204
2270
  var STATE_DIR = ".ts-cloud/state";
2205
2271
  function driverStatePath(stackName) {
2206
- return join2(process.cwd(), STATE_DIR, `${stackName}.json`);
2272
+ return join3(process.cwd(), STATE_DIR, `${stackName}.json`);
2207
2273
  }
2208
2274
  async function readDriverState(stackName) {
2209
2275
  try {
@@ -2215,7 +2281,7 @@ async function readDriverState(stackName) {
2215
2281
  }
2216
2282
  async function writeDriverState(stackName, state) {
2217
2283
  const path = driverStatePath(stackName);
2218
- await mkdir(join2(process.cwd(), STATE_DIR), { recursive: true });
2284
+ await mkdir(join3(process.cwd(), STATE_DIR), { recursive: true });
2219
2285
  await writeFile(path, `${JSON.stringify(state, null, 2)}
2220
2286
  `, "utf8");
2221
2287
  }
@@ -2236,9 +2302,6 @@ function formatSshFailure(error) {
2236
2302
  return `Remote SSH command failed${status}${signal}${output ? `
2237
2303
  ${output}` : ""}`;
2238
2304
  }
2239
- function expandHome(path) {
2240
- return path.startsWith("~/") ? join3(homedir(), path.slice(2)) : path;
2241
- }
2242
2305
 
2243
2306
  class HetznerDriver {
2244
2307
  name = "hetzner";
@@ -2252,12 +2315,18 @@ class HetznerDriver {
2252
2315
  bootWait;
2253
2316
  constructor(options = {}) {
2254
2317
  this.client = options.client ?? new HetznerClient({
2255
- apiToken: resolveHetznerApiToken(options.apiToken)
2318
+ apiToken: resolveHetznerApiToken2(options.apiToken)
2319
+ });
2320
+ const settings = resolveHetznerSettings(undefined, {
2321
+ sshPrivateKeyPath: options.sshPrivateKeyPath,
2322
+ sshPublicKeyPath: options.sshPublicKeyPath,
2323
+ sshUser: options.sshUser,
2324
+ location: options.location
2256
2325
  });
2257
- this.sshPrivateKeyPath = expandHome(options.sshPrivateKeyPath || process.env.HCLOUD_SSH_KEY || "~/.ssh/id_ed25519");
2258
- this.sshPublicKeyPath = expandHome(options.sshPublicKeyPath || process.env.HCLOUD_SSH_PUBLIC_KEY || `${this.sshPrivateKeyPath}.pub`);
2259
- this.sshUser = options.sshUser || process.env.HCLOUD_SSH_USER || "root";
2260
- this.location = options.location || process.env.HCLOUD_LOCATION || "fsn1";
2326
+ this.sshPrivateKeyPath = settings.sshPrivateKeyPath;
2327
+ this.sshPublicKeyPath = settings.sshPublicKeyPath;
2328
+ this.sshUser = settings.sshUser;
2329
+ this.location = settings.location;
2261
2330
  this.waitForBoot = options.waitForBoot ?? true;
2262
2331
  this.bootWait = {
2263
2332
  sshIntervalMs: options.bootWait?.sshIntervalMs ?? 5000,
@@ -2324,7 +2393,7 @@ class HetznerDriver {
2324
2393
  });
2325
2394
  const userData = wrapCloudInitUserData(bootstrap);
2326
2395
  const serverType = resolveHetznerServerType(compute.size);
2327
- const image = compute.image || config.hetzner?.image || "ubuntu-24.04";
2396
+ const image = resolveHetznerImage(config);
2328
2397
  const firewallName = `${slug}-${environment}-app-fw`;
2329
2398
  const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
2330
2399
  allowSsh: compute.allowSsh !== false,
@@ -2367,7 +2436,7 @@ class HetznerDriver {
2367
2436
  const compute = config.infrastructure.compute;
2368
2437
  const stackName = resolveProjectStackName(config, environment);
2369
2438
  const serverType = resolveHetznerServerType(compute.size);
2370
- const image = compute.image || config.hetzner?.image || "ubuntu-24.04";
2439
+ const image = resolveHetznerImage(config);
2371
2440
  const location = config.hetzner?.location || this.location;
2372
2441
  const baked = compute.bakedImage === true;
2373
2442
  const existingState = await readDriverState(stackName);
@@ -2435,7 +2504,7 @@ class HetznerDriver {
2435
2504
  versions: compute.php?.versions,
2436
2505
  default: compute.php?.default,
2437
2506
  extensions: compute.php?.extensions,
2438
- installNginx: compute.webServer !== "rpx",
2507
+ installNginx: !usesRpxProxy(compute),
2439
2508
  optimizeForProduction: compute.php?.optimizeForProduction,
2440
2509
  ini: compute.php?.ini
2441
2510
  });
@@ -2522,7 +2591,7 @@ class HetznerDriver {
2522
2591
  const compute = config.infrastructure.compute;
2523
2592
  const stackName = resolveProjectStackName(config, environment);
2524
2593
  const serverType = resolveHetznerServerType(compute.size);
2525
- const image = compute.image || config.hetzner?.image || "ubuntu-24.04";
2594
+ const image = resolveHetznerImage(config);
2526
2595
  const location = config.hetzner?.location || this.location;
2527
2596
  const baked = compute.bakedImage === true;
2528
2597
  const sites = config.sites || {};
@@ -2805,11 +2874,11 @@ class HetznerDriver {
2805
2874
  role: "app",
2806
2875
  stackName: resolveProjectStackName(options.config, options.environment)
2807
2876
  });
2808
- const first = targets[0];
2877
+ const first2 = targets[0];
2809
2878
  return {
2810
2879
  deployStoragePath: "/var/ts-cloud/staging",
2811
- appInstanceId: first?.id,
2812
- appPublicIp: first?.publicIp,
2880
+ appInstanceId: first2?.id,
2881
+ appPublicIp: first2?.publicIp,
2813
2882
  sshUser: this.sshUser
2814
2883
  };
2815
2884
  }
@@ -3131,6 +3200,7 @@ function createCloudDriver(options) {
3131
3200
  return new HetznerDriver({
3132
3201
  apiToken: options.config.hetzner?.apiToken,
3133
3202
  sshPrivateKeyPath: options.config.hetzner?.sshPrivateKeyPath,
3203
+ sshPublicKeyPath: options.config.hetzner?.sshPublicKeyPath,
3134
3204
  sshUser: options.config.hetzner?.sshUser,
3135
3205
  location: options.config.hetzner?.location
3136
3206
  });
@@ -3475,8 +3545,8 @@ function createBoxProvisioner(options) {
3475
3545
  return new AwsBoxProvisioner(options.aws);
3476
3546
  }
3477
3547
  // src/drivers/shared/env-file.ts
3478
- function formatEnvFile(env) {
3479
- return Object.entries(env).map(([k, v]) => `${k}=${quoteEnvValue(String(v))}`).join(`
3548
+ function formatEnvFile(env2) {
3549
+ return Object.entries(env2).map(([k, v]) => `${k}=${quoteEnvValue(String(v))}`).join(`
3480
3550
  `);
3481
3551
  }
3482
3552
  function quoteEnvValue(value) {
@@ -4041,8 +4111,8 @@ function defaultDeployScriptFor(type) {
4041
4111
  function substituteBins(line, phpBin) {
4042
4112
  return line.replace(/^php\s+/, `${phpBin} `).replace(/(\s)php\s+artisan\s+/g, `$1${phpBin} artisan `);
4043
4113
  }
4044
- function writeSharedEnv(sharedEnvPath, env) {
4045
- const body = formatEnvFile(env);
4114
+ function writeSharedEnv(sharedEnvPath, env2) {
4115
+ const body = formatEnvFile(env2);
4046
4116
  return [
4047
4117
  `cat > ${sharedEnvPath} <<'TS_CLOUD_ENV_EOF'`,
4048
4118
  body,
@@ -4418,7 +4488,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4418
4488
  appBase: appBase2,
4419
4489
  defaultPhpVersion: phpVersion
4420
4490
  });
4421
- const useNginx = compute2?.webServer !== "rpx";
4491
+ const useNginx = !usesRpxProxy(compute2);
4422
4492
  const sslProvider = resolveSslProvider(site);
4423
4493
  const customCert = sslProvider === "custom" && site.ssl?.certPath && site.ssl?.keyPath ? { certPath: site.ssl.certPath, keyPath: site.ssl.keyPath } : undefined;
4424
4494
  const poolScript = site.isolation ? buildPhpFpmPoolScript({ siteName, appBase: appBase2 }) : [];
@@ -4500,7 +4570,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4500
4570
  healthCheckPath: site.healthCheck?.path
4501
4571
  });
4502
4572
  const compute = config.infrastructure?.compute;
4503
- const wantsNginxStatic = kind === "server-static" && compute?.webServer !== "rpx" && !!site.domain;
4573
+ const wantsNginxStatic = kind === "server-static" && !usesRpxProxy(compute) && !!site.domain;
4504
4574
  const staticVhost = wantsNginxStatic ? buildNginxVhostScript({
4505
4575
  siteName,
4506
4576
  domain: site.domain,
@@ -4761,4 +4831,4 @@ function buildCloudFrontOriginConfig(options) {
4761
4831
  CustomErrorResponses: { Quantity: 0 }
4762
4832
  };
4763
4833
  }
4764
- 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 };
4834
+ 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, resolveHetznerLocation, HetznerClient, resolveHetznerApiToken2 as 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 };