@stacksjs/ts-cloud 0.7.17 → 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,1578 +545,1584 @@ function buildNginxServiceScript(projectDir = "/opt/pantry") {
545
545
  ];
546
546
  }
547
547
 
548
- // src/drivers/shared/ufw.ts
549
- var UFW_BASE_PORTS = [80, 443];
550
- function buildUfwScript(firewall = {}) {
551
- if (firewall.enabled === false)
552
- return [];
553
- const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => p > 0).sort((a, b) => a - b);
554
- const lines = [
555
- "export DEBIAN_FRONTEND=noninteractive",
556
- "apt-get install -y ufw",
557
- "ufw default deny incoming",
558
- "ufw default allow outgoing",
559
- "ufw allow OpenSSH"
560
- ];
561
- for (const port of ports)
562
- lines.push(`ufw allow ${port}/tcp`);
563
- lines.push("ufw --force enable");
564
- return lines;
565
- }
566
-
567
- // src/drivers/shared/maintenance.ts
568
- function buildAutoUpdatesScript(enabled2 = true) {
569
- if (!enabled2)
570
- return [];
571
- return [
572
- "export DEBIAN_FRONTEND=noninteractive",
573
- "apt-get install -y unattended-upgrades",
574
- "cat > /etc/apt/apt.conf.d/20auto-upgrades <<'TS_CLOUD_AUTOUPD_EOF'",
575
- 'APT::Periodic::Update-Package-Lists "1";',
576
- 'APT::Periodic::Unattended-Upgrade "1";',
577
- 'APT::Periodic::Download-Upgradeable-Packages "1";',
578
- 'APT::Periodic::AutocleanInterval "7";',
579
- "TS_CLOUD_AUTOUPD_EOF",
580
- "systemctl enable unattended-upgrades 2>/dev/null || true",
581
- "systemctl restart unattended-upgrades 2>/dev/null || true"
582
- ];
583
- }
584
-
585
- // src/drivers/shared/monitoring.ts
586
- var METRICS_PATH = "/var/lib/ts-cloud/metrics.json";
587
- var ALERT_STATE_PATH = "/var/lib/ts-cloud/alert-state";
588
- var DEFAULT_CPU_LOAD_PER_CORE = 2;
589
- var DEFAULT_MEM_PERCENT = 90;
590
- var DEFAULT_DISK_PERCENT = 90;
591
- var SERVICE_PROBES = [
592
- ["nginx", 80],
593
- ["phpFpm", 9074],
594
- ["mysql", 3306],
595
- ["postgres", 5432],
596
- ["redis", 6379],
597
- ["meilisearch", 7700]
598
- ];
599
- function resolveMonitoring(monitoring = true) {
600
- const obj = typeof monitoring === "object" ? monitoring : {};
601
- const enabled2 = typeof monitoring === "boolean" ? monitoring : monitoring.enabled !== false;
602
- return {
603
- enabled: enabled2,
604
- cpuLoadPerCore: obj.alerts?.cpuLoadPerCore ?? DEFAULT_CPU_LOAD_PER_CORE,
605
- memPercent: obj.alerts?.memPercent ?? DEFAULT_MEM_PERCENT,
606
- diskPercent: obj.alerts?.diskPercent ?? DEFAULT_DISK_PERCENT
607
- };
548
+ // src/deploy/site-target.ts
549
+ function siteInstallBase(slug, siteName) {
550
+ return `/var/www/${slug}-${siteName}`;
608
551
  }
609
- function buildMonitoringScript(monitoring = true) {
610
- const { enabled: enabled2, cpuLoadPerCore, memPercent, diskPercent } = resolveMonitoring(monitoring);
611
- if (!enabled2)
612
- return [];
613
- const probeLines = SERVICE_PROBES.map(([name, port]) => `SVC_${name.toUpperCase()}=$(probe ${port})`);
614
- const servicesJson = SERVICE_PROBES.map(([name]) => `"${name}":"$SVC_${name.toUpperCase()}"`).join(",");
615
- return [
616
- "mkdir -p /var/lib/ts-cloud",
617
- "cat > /usr/local/bin/ts-cloud-metrics.sh <<'TS_CLOUD_METRICS_EOF'",
618
- "#!/bin/bash",
619
- "set -uo pipefail",
620
- "LOAD=$(cut -d' ' -f1 /proc/loadavg)",
621
- "CPUS=$(nproc)",
622
- "MEM_TOTAL=$(free -m | awk '/^Mem:/{print $2}')",
623
- "MEM_USED=$(free -m | awk '/^Mem:/{print $3}')",
624
- "SWAP_TOTAL=$(free -m | awk '/^Swap:/{print $2}')",
625
- "SWAP_USED=$(free -m | awk '/^Swap:/{print $3}')",
626
- `DISK_PCT=$(df -P / | awk 'NR==2{gsub("%","",$5); print $5}')`,
627
- "UPTIME_SEC=$(cut -d' ' -f1 /proc/uptime | cut -d. -f1)",
628
- `RX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{rx+=$3} END{print rx+0}' /proc/net/dev)`,
629
- `TX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{tx+=$11} END{print tx+0}' /proc/net/dev)`,
630
- "probe(){ (exec 3<>/dev/tcp/127.0.0.1/$1) 2>/dev/null && echo up || echo down; }",
631
- ...probeLines,
632
- "LOAD=${LOAD:-0}; CPUS=${CPUS:-1}; MEM_TOTAL=${MEM_TOTAL:-0}; MEM_USED=${MEM_USED:-0}",
633
- "SWAP_TOTAL=${SWAP_TOTAL:-0}; SWAP_USED=${SWAP_USED:-0}; DISK_PCT=${DISK_PCT:-0}",
634
- "UPTIME_SEC=${UPTIME_SEC:-0}; RX_BYTES=${RX_BYTES:-0}; TX_BYTES=${TX_BYTES:-0}",
635
- "MEM_PCT=$(( MEM_TOTAL > 0 ? MEM_USED * 100 / MEM_TOTAL : 0 ))",
636
- `cat > ${METRICS_PATH}.tmp <<JSON`,
637
- '{"load":$LOAD,"cpus":$CPUS,"memTotalMb":$MEM_TOTAL,"memUsedMb":$MEM_USED,"memUsedPct":$MEM_PCT,"swapTotalMb":$SWAP_TOTAL,"swapUsedMb":$SWAP_USED,"diskUsedPct":$DISK_PCT,"uptimeSec":$UPTIME_SEC,"network":{"rxBytes":$RX_BYTES,"txBytes":$TX_BYTES},"services":{' + servicesJson + "}}",
638
- "JSON",
639
- `mv -f ${METRICS_PATH}.tmp ${METRICS_PATH}`,
640
- 'ALERTS=""',
641
- `if awk -v l="$LOAD" -v c="$CPUS" -v t=${cpuLoadPerCore} 'BEGIN{exit !(c>0 && l/c > t)}'; then ALERTS="$ALERTS load=$LOAD/${cpuLoadPerCore}xCPU"; fi`,
642
- `if [ "\${MEM_PCT:-0}" -ge ${memPercent} ]; then ALERTS="$ALERTS mem=\${MEM_PCT}%"; fi`,
643
- `if [ "\${DISK_PCT:-0}" -ge ${diskPercent} ]; then ALERTS="$ALERTS disk=\${DISK_PCT}%"; fi`,
644
- `PREV=$(cat ${ALERT_STATE_PATH} 2>/dev/null || echo ok)`,
645
- 'if [ -n "$ALERTS" ]; then',
646
- ' if [ "$PREV" != alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "⚠️ $(hostname): resource alert —$ALERTS" || true; fi',
647
- ` echo alert > ${ALERT_STATE_PATH}`,
648
- "else",
649
- ' if [ "$PREV" = alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "✅ $(hostname): resource usage back to normal" || true; fi',
650
- ` echo ok > ${ALERT_STATE_PATH}`,
651
- "fi",
652
- "TS_CLOUD_METRICS_EOF",
653
- "chmod +x /usr/local/bin/ts-cloud-metrics.sh",
654
- "cat > /etc/systemd/system/ts-cloud-metrics.service <<'TS_CLOUD_METRICS_SVC_EOF'",
655
- "[Unit]",
656
- "Description=ts-cloud metrics collector",
657
- "",
658
- "[Service]",
659
- "Type=oneshot",
660
- "ExecStart=/usr/local/bin/ts-cloud-metrics.sh",
661
- "TS_CLOUD_METRICS_SVC_EOF",
662
- "cat > /etc/systemd/system/ts-cloud-metrics.timer <<'TS_CLOUD_METRICS_TMR_EOF'",
663
- "[Unit]",
664
- "Description=Run ts-cloud metrics collector every minute",
665
- "",
666
- "[Timer]",
667
- "OnBootSec=60",
668
- "OnUnitActiveSec=60",
669
- "",
670
- "[Install]",
671
- "WantedBy=timers.target",
672
- "TS_CLOUD_METRICS_TMR_EOF",
673
- "systemctl daemon-reload",
674
- "systemctl enable ts-cloud-metrics.timer",
675
- "systemctl start ts-cloud-metrics.timer"
676
- ];
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);
677
560
  }
678
-
679
- // src/drivers/shared/ssh-keys.ts
680
- var BLOCK_BEGIN = "# >>> ts-cloud managed keys >>>";
681
- var BLOCK_END = "# <<< ts-cloud managed keys <<<";
682
- var DEFAULT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
683
- function buildAuthorizedKeysScript(keys = [], options = {}) {
684
- if (keys.length === 0)
685
- return [];
686
- const path = options.path ?? DEFAULT_AUTHORIZED_KEYS;
687
- const dir = path.replace(/\/[^/]*$/, "");
688
- const block = [
689
- BLOCK_BEGIN,
690
- ...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
691
- BLOCK_END
692
- ].join(`
693
- `);
694
- return [
695
- `mkdir -p ${dir}`,
696
- `touch ${path}`,
697
- `sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}`,
698
- `cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`,
699
- block,
700
- "TS_CLOUD_KEYS_EOF",
701
- `chmod 600 ${path}`
702
- ];
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";
703
569
  }
704
- function escapeSed(value) {
705
- return value.replace(/[.*[\]\\/^$]/g, "\\$&");
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";
706
579
  }
707
-
708
- // src/drivers/shared/notifications.ts
709
- function wantsEvent(config, event) {
710
- return !config.events || config.events.includes(event);
580
+ function hasComputeConfigured(config) {
581
+ return config.infrastructure?.compute != null;
711
582
  }
712
- async function sendNotifications(config, event, message, options = {}) {
713
- if (!config)
714
- return [];
715
- const fetchImpl = options.fetchImpl ?? globalThis.fetch;
716
- const attempted = [];
717
- const tasks = [];
718
- const post = (url, body) => fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).catch(() => {
719
- return;
720
- });
721
- if (config.slack?.webhookUrl && wantsEvent(config, event)) {
722
- attempted.push("slack");
723
- tasks.push(post(config.slack.webhookUrl, { text: message }));
724
- }
725
- if (config.discord?.webhookUrl && wantsEvent(config, event)) {
726
- attempted.push("discord");
727
- tasks.push(post(config.discord.webhookUrl, { content: message }));
728
- }
729
- if (config.telegram?.botToken && config.telegram.chatId && wantsEvent(config, event)) {
730
- attempted.push("telegram");
731
- const url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`;
732
- tasks.push(post(url, { chat_id: config.telegram.chatId, text: message }));
733
- }
734
- if (config.webhook?.url && wantsEvent(config, event)) {
735
- attempted.push("webhook");
736
- if ((config.webhook.method || "POST") === "GET") {
737
- const sep = config.webhook.url.includes("?") ? "&" : "?";
738
- const url = `${config.webhook.url}${sep}event=${encodeURIComponent(event)}&message=${encodeURIComponent(message)}`;
739
- tasks.push(fetchImpl(url, { method: "GET" }).catch(() => {
740
- return;
741
- }));
742
- } else {
743
- tasks.push(post(config.webhook.url, { event, message }));
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;
744
595
  }
745
- }
746
- if (config.email?.to && wantsEvent(config, event)) {
747
- attempted.push("email");
748
- tasks.push(sendEmailNotification(config.email, event, message));
749
- }
750
- await Promise.all(tasks);
751
- return attempted;
752
- }
753
- async function sendEmailNotification(email, event, message) {
754
- try {
755
- const mod = await import("./chunk-pqfzdg68.js");
756
- await mod.email.send({
757
- to: email.to,
758
- from: email.from,
759
- subject: `[ts-cloud] ${event}`,
760
- text: message
761
- });
762
- } catch {}
763
- }
764
- function resolveNotifications(project, site) {
765
- return site ?? project;
766
- }
767
- function buildNotifierScript(config) {
768
- if (!config)
769
- return [];
770
- const curls = [];
771
- if (config.slack?.webhookUrl)
772
- curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"text\\":\\"$MSG\\"}" ${config.slack.webhookUrl} || true`);
773
- if (config.discord?.webhookUrl)
774
- curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"content\\":\\"$MSG\\"}" ${config.discord.webhookUrl} || true`);
775
- if (config.telegram?.botToken && config.telegram.chatId)
776
- curls.push(`curl -fsS -X POST "https://api.telegram.org/bot${config.telegram.botToken}/sendMessage" --data-urlencode "chat_id=${config.telegram.chatId}" --data-urlencode "text=$MSG" || true`);
777
- if (config.webhook?.url)
778
- curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"message\\":\\"$MSG\\"}" ${config.webhook.url} || true`);
779
- if (curls.length === 0)
780
- return [];
781
- return [
782
- "cat > /usr/local/bin/ts-cloud-notify <<'TS_CLOUD_NOTIFY_EOF'",
783
- "#!/bin/bash",
784
- 'MSG="$1"',
785
- ...curls,
786
- "TS_CLOUD_NOTIFY_EOF",
787
- "chmod +x /usr/local/bin/ts-cloud-notify"
788
- ];
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 };
789
663
  }
790
664
 
791
- // src/drivers/shared/backups.ts
792
- var BACKUP_OUTPUT_DIR = "/var/backups/ts-cloud";
793
- var BACKUP_CONFIG_PATH = "/etc/ts-cloud/backups.config.ts";
794
- var BACKUP_CRON_PATH = "/etc/cron.d/ts-cloud-backups";
795
- var BACKUP_RUNNER_PATH = "/usr/local/bin/ts-cloud-backup.sh";
796
- function backupEntryFor(database) {
797
- if (!database.name)
798
- return null;
799
- const host = database.host || "127.0.0.1";
800
- const isPostgres = database.engine === "postgres";
801
- const type = isPostgres ? "postgresql" : "mysql";
802
- const port = database.port ?? (isPostgres ? 5432 : 3306);
803
- const ts = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
804
- return [
805
- " {",
806
- ` type: '${type}',`,
807
- ` name: '${ts(database.name)}',`,
808
- " connection: {",
809
- ` hostname: '${ts(host)}',`,
810
- ` port: ${port},`,
811
- ` database: '${ts(database.name)}',`,
812
- ` username: '${ts(database.username || database.name)}',`,
813
- ` password: '${ts(database.password || "")}',`,
814
- " ssl: false,",
815
- " },",
816
- " includeSchema: true,",
817
- " includeData: true,",
818
- " },"
819
- ].join(`
820
- `);
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;
821
677
  }
822
- function buildBackupsConfigTs(database, backups) {
823
- const entry = database ? backupEntryFor(database) : null;
824
- const destinations = backups.bucket ? [
825
- " destinations: [",
826
- " {",
827
- " type: 's3',",
828
- ` bucket: '${backups.bucket}',`,
829
- " prefix: 'db-backups',",
830
- ...backups.endpoint ? [` endpoint: '${backups.endpoint}',`] : [],
831
- " optional: false,",
832
- " },",
833
- " ],"
834
- ] : [];
835
- return [
836
- "import type { BackupConfig } from 'ts-backups'",
837
- "",
838
- "const config: BackupConfig = {",
839
- " verbose: true,",
840
- ` outputPath: '${BACKUP_OUTPUT_DIR}',`,
841
- " retention: {",
842
- ` count: ${backups.retentionCount ?? 5},`,
843
- ` maxAge: ${backups.retentionDays ?? 30},`,
844
- " },",
845
- " databases: [",
846
- ...entry ? [entry] : [],
847
- " ],",
848
- ...destinations,
849
- "}",
850
- "",
851
- "export default config",
852
- ""
853
- ].join(`
854
- `);
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
+ };
855
687
  }
856
- function buildBackupProvisionScript(options) {
857
- const { database, backups } = options;
858
- if (!backups.enabled)
859
- return [];
860
- const schedule = backups.schedule || "0 2 * * *";
861
- const configTs = buildBackupsConfigTs(database, backups);
862
- return [
863
- "export DEBIAN_FRONTEND=noninteractive",
864
- `mkdir -p /etc/ts-cloud ${BACKUP_OUTPUT_DIR}`,
865
- "command -v bun >/dev/null 2>&1 || (curl -fsSL https://bun.sh/install | bash && ln -sf /root/.bun/bin/bun /usr/local/bin/bun)",
866
- "bun add -g ts-backups || true",
867
- `cat > ${BACKUP_CONFIG_PATH} <<'TS_CLOUD_BACKUP_CFG_EOF'`,
868
- configTs.replace(/\n$/, ""),
869
- "TS_CLOUD_BACKUP_CFG_EOF",
870
- `cat > ${BACKUP_RUNNER_PATH} <<'TS_CLOUD_BACKUP_RUN_EOF'`,
871
- "#!/bin/bash",
872
- "set -uo pipefail",
873
- 'export PATH="/root/.bun/bin:/usr/local/bin:$PATH"',
874
- 'notify() { [ -x /usr/local/bin/ts-cloud-notify ] && /usr/local/bin/ts-cloud-notify "$1" || true; }',
875
- "cd /etc/ts-cloud",
876
- 'if ! ts-backups backup --config /etc/ts-cloud/backups.config.ts; then notify "❌ ts-cloud backup failed"; exit 1; fi',
877
- "TS_CLOUD_BACKUP_RUN_EOF",
878
- `chmod +x ${BACKUP_RUNNER_PATH}`,
879
- `cat > ${BACKUP_CRON_PATH} <<'TS_CLOUD_BACKUP_CRON_EOF'`,
880
- `${schedule} root ${BACKUP_RUNNER_PATH} >> /var/log/ts-cloud-backup.log 2>&1`,
881
- "TS_CLOUD_BACKUP_CRON_EOF",
882
- `chmod 644 ${BACKUP_CRON_PATH}`
883
- ];
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;
884
695
  }
885
- function engineSocket(engine) {
886
- return engine === "mariadb" ? "/var/lib/pantry/mariadb/mariadbd.sock" : "/var/lib/pantry/mysql/mysqld.sock";
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";
887
700
  }
888
- function buildBackupRestoreScript(database, options = {}) {
889
- if (!database?.name)
890
- return [];
891
- const name = database.name;
892
- const isPg = database.engine === "postgres";
893
- const locate = options.from ? `TS_CLOUD_DUMP="${options.from}"` : `TS_CLOUD_DUMP="$(find ${BACKUP_OUTPUT_DIR} -type f -name '*${name}*.sql' -o -type f -name '*${name}*.sql.gz' 2>/dev/null | xargs -r ls -1t 2>/dev/null | head -1)"`;
894
- const client = isPg ? `psql -h 127.0.0.1 -p ${database.port ?? 5432} -U postgres -d "${name}"` : `mysql --socket=${engineSocket(database.engine)} -u root "${name}"`;
895
- return [
896
- "set -uo pipefail",
897
- 'eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true',
898
- locate,
899
- '[ -n "$TS_CLOUD_DUMP" ] && [ -f "$TS_CLOUD_DUMP" ] || { echo "no backup dump found to restore" >&2; exit 1; }',
900
- 'echo "[ts-cloud] restoring ' + name + ' from $TS_CLOUD_DUMP"',
901
- `case "$TS_CLOUD_DUMP" in *.gz) gunzip -c "$TS_CLOUD_DUMP" ;; *) cat "$TS_CLOUD_DUMP" ;; esac | ${client}`,
902
- 'echo "restore complete"'
903
- ];
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}`);
904
705
  }
905
-
906
- // src/drivers/shared/compute-provision.ts
907
- function buildComputeProvisionScripts(config) {
908
- const compute = config.infrastructure?.compute ?? {};
909
- const phpBox = compute.runtime === "php" || !!compute.php;
910
- const needsPantry = phpBox || !!compute.managedServices;
911
- const pantryBootstrap = needsPantry ? buildPantryBootstrapScript() : [];
912
- const useNginx = compute.webServer !== "rpx";
913
- const phpProvision = phpBox ? [
914
- ...pantryBootstrap,
915
- ...buildPhpProvisionScript({
916
- versions: compute.php?.versions,
917
- default: compute.php?.default,
918
- extensions: compute.php?.extensions,
919
- installNginx: useNginx,
920
- optimizeForProduction: compute.php?.optimizeForProduction,
921
- ini: compute.php?.ini
922
- }),
923
- ...useNginx ? buildNginxServiceScript() : []
924
- ] : undefined;
925
- const extras = [];
926
- if (!phpBox && needsPantry)
927
- extras.push(...pantryBootstrap);
928
- extras.push(...buildNotifierScript(config.notifications));
929
- if (compute.managedServices) {
930
- extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices));
931
- }
932
- extras.push(...buildUfwScript(compute.firewall ?? (phpBox ? { enabled: true } : { enabled: false })));
933
- extras.push(...buildAutoUpdatesScript(compute.autoUpdates ?? phpBox));
934
- extras.push(...buildMonitoringScript(compute.monitoring ?? phpBox));
935
- extras.push(...buildAuthorizedKeysScript(compute.sshKeys));
936
- if (compute.backups?.enabled) {
937
- extras.push(...buildBackupProvisionScript({
938
- database: config.infrastructure?.appDatabase,
939
- backups: compute.backups
940
- }));
941
- }
942
- return {
943
- runtime: compute.runtime || "bun",
944
- runtimeVersion: compute.runtimeVersion || "latest",
945
- phpBox,
946
- phpProvision,
947
- servicesProvision: extras.length > 0 ? extras : undefined
948
- };
949
- }
950
-
951
- // src/drivers/shared/ubuntu-bootstrap.ts
952
- function buildUbuntuBootstrapScript(options = {}) {
953
- const {
954
- runtime = "bun",
955
- runtimeVersion = "latest",
956
- systemPackages = [],
957
- database,
958
- phpProvision,
959
- servicesProvision,
960
- caddyfile,
961
- rpxProvision,
962
- baked = false
963
- } = options;
964
- const packages = new Set(systemPackages);
965
- if (database === "sqlite")
966
- packages.add("sqlite3");
967
- else if (database === "mysql")
968
- packages.add("mysql-client");
969
- else if (database === "postgres")
970
- packages.add("postgresql-client");
971
- let script = `#!/bin/bash
972
- set -euo pipefail
973
- `;
974
- if (!baked) {
975
- script += `
976
- export DEBIAN_FRONTEND=noninteractive
977
- apt-get update -y
978
- apt-get upgrade -y
979
- apt-get install -y curl tar gzip unzip git ca-certificates
980
- `;
981
- if (packages.size > 0) {
982
- script += `
983
- apt-get install -y ${[...packages].join(" ")}
984
- `;
985
- }
986
- if (phpProvision && phpProvision.length > 0) {
987
- script += `
988
- ${phpProvision.join(`
989
- `)}
990
- `;
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;
991
726
  }
992
- if (servicesProvision && servicesProvision.length > 0) {
993
- script += `
994
- ${servicesProvision.join(`
995
- `)}
996
- `;
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
+ });
997
752
  }
998
- if (runtime === "bun") {
999
- script += `
1000
- # cloud-init runs this with \`set -u\` and NO $HOME set. bun's install.sh
1001
- # dereferences $HOME even when BUN_INSTALL is set, so without this the install
1002
- # aborts with "HOME: unbound variable" and /usr/local/bin/bun never appears —
1003
- # breaking a from-scratch provision (adopting an existing box masked it).
1004
- export HOME="\${HOME:-/root}"
1005
- export BUN_INSTALL="/root/.bun"
1006
- # bun.sh's installer references $HOME internally and runs under this
1007
- # script's \`set -u\` (inherited by the piped bash) — cloud-init's runcmd
1008
- # environment doesn't export HOME by default, so without this the
1009
- # installer dies with "HOME: unbound variable" partway through (after
1010
- # the binary is already downloaded, so it looks like a partial success)
1011
- # and \`set -e\` aborts everything after it: the bun symlink, /var/www +
1012
- # /var/ts-cloud dirs, and the whole rpx gateway install/systemd setup
1013
- # never run. Confirmed against a real Hetzner box (stacksjs/status#1
1014
- # Phase 9 e2e deploy) — cloud-init reported success with none of that
1015
- # actually done.
1016
- export HOME="\${HOME:-/root}"
1017
- curl -fsSL https://bun.sh/install | bash${runtimeVersion === "latest" ? "" : ` -s "bun-v${runtimeVersion}"`}
1018
- ln -sf /root/.bun/bin/bun /usr/local/bin/bun
1019
- echo 'export BUN_INSTALL="/root/.bun"' > /etc/profile.d/bun.sh
1020
- echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> /etc/profile.d/bun.sh
1021
- `;
1022
- } else if (runtime === "node") {
1023
- const nodeMajor = runtimeVersion === "latest" || !runtimeVersion ? "20" : runtimeVersion.split(".")[0];
1024
- script += `
1025
- curl -fsSL https://deb.nodesource.com/setup_${nodeMajor}.x | bash -
1026
- apt-get install -y nodejs
1027
- ln -sf /usr/bin/node /usr/local/bin/node
1028
- ln -sf /usr/bin/npm /usr/local/bin/npm
1029
- `;
1030
- } else if (runtime === "deno") {
1031
- script += `
1032
- curl -fsSL https://deno.land/install.sh | sh
1033
- ln -sf /root/.deno/bin/deno /usr/local/bin/deno
1034
- `;
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);
1035
764
  }
1036
765
  }
1037
- script += `
1038
- mkdir -p /var/www /var/ts-cloud/staging /var/ts-cloud/releases
1039
- `;
1040
- if (caddyfile) {
1041
- const escaped = caddyfile.replace(/\$/g, "\\$");
1042
- script += `
1043
- ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
1044
- curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=\${ARCH}" -o /usr/local/bin/caddy
1045
- chmod +x /usr/local/bin/caddy
1046
-
1047
- getent group caddy >/dev/null || groupadd --system caddy
1048
- getent passwd caddy >/dev/null || useradd --system --gid caddy \\
1049
- --create-home --home-dir /var/lib/caddy \\
1050
- --shell /usr/sbin/nologin --comment "Caddy web server" caddy
1051
-
1052
- mkdir -p /etc/caddy /var/lib/caddy /var/log/caddy
1053
- chown -R caddy:caddy /var/lib/caddy /var/log/caddy
1054
-
1055
- cat > /etc/systemd/system/caddy.service <<'CADDY_UNIT_EOF'
1056
- [Unit]
1057
- Description=Caddy
1058
- Documentation=https://caddyserver.com/docs/
1059
- After=network.target network-online.target
1060
- Requires=network-online.target
1061
-
1062
- [Service]
1063
- Type=notify
1064
- User=caddy
1065
- Group=caddy
1066
- ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
1067
- ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
1068
- TimeoutStopSec=5s
1069
- LimitNOFILE=1048576
1070
- PrivateTmp=true
1071
- ProtectSystem=full
1072
- AmbientCapabilities=CAP_NET_BIND_SERVICE
1073
-
1074
- [Install]
1075
- WantedBy=multi-user.target
1076
- CADDY_UNIT_EOF
1077
-
1078
- cat > /etc/caddy/Caddyfile <<'CADDY_CONFIG_EOF'
1079
- ${escaped}
1080
- CADDY_CONFIG_EOF
1081
-
1082
- systemctl daemon-reload
1083
- systemctl enable caddy
1084
- systemctl start caddy
1085
- `;
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
+ };
1086
785
  }
1087
- if (rpxProvision && rpxProvision.length > 0) {
1088
- const body = rpxProvision[0] === "set -euo pipefail" ? rpxProvision.slice(1) : rpxProvision;
1089
- script += `
1090
- ${body.join(`
1091
- `)}
1092
- `;
786
+ if (options.proxy.onDemandTls) {
787
+ config.acmeChallengeWebroot = options.proxy.acmeWebroot ?? DEFAULT_ACME_WEBROOT;
1093
788
  }
1094
- script += `
1095
- echo "ts-cloud bootstrap complete instance is ready for site deploys"
1096
- `;
1097
- return script;
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);
1098
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'
1099
810
 
1100
- // src/drivers/aws/provision.ts
1101
- var UBUNTU_AMI_SSM_PARAM = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id";
1102
- function awsComputeIngressRules(config) {
1103
- const rules = [
1104
- { port: 22, protocol: "tcp", cidr: "0.0.0.0/0" },
1105
- { port: 80, protocol: "tcp", cidr: "0.0.0.0/0" },
1106
- { port: 443, protocol: "tcp", cidr: "0.0.0.0/0" }
1107
- ];
1108
- const extra = new Set;
1109
- for (const site of Object.values(config.sites || {})) {
1110
- if (site && typeof site.port === "number" && ![22, 80, 443].includes(site.port))
1111
- extra.add(site.port);
1112
- }
1113
- for (const port of extra)
1114
- rules.push({ port, protocol: "tcp", cidr: "0.0.0.0/0" });
1115
- return rules;
811
+ const config = ${json} as const
812
+
813
+ await startProxies(config as any)
814
+ `;
1116
815
  }
1117
- function buildAwsUserData(config) {
1118
- const compute = config.infrastructure?.compute ?? {};
1119
- const provision = buildComputeProvisionScripts(config);
1120
- return buildUbuntuBootstrapScript({
1121
- runtime: provision.runtime,
1122
- runtimeVersion: provision.runtimeVersion,
1123
- systemPackages: compute.systemPackages,
1124
- database: config.infrastructure?.database,
1125
- phpProvision: provision.phpProvision,
1126
- servicesProvision: provision.servicesProvision,
1127
- baked: compute.bakedImage === true
1128
- });
816
+ function usesRpxProxy(compute) {
817
+ return compute?.webServer === "rpx" || compute?.proxy?.engine === "rpx";
1129
818
  }
1130
- function encodeUserData(userData) {
1131
- return Buffer.from(userData, "utf8").toString("base64");
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
+ }
1132
859
  }
1133
- function resolveAwsImageId(config) {
1134
- return config.infrastructure?.compute?.image ?? null;
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] } } : {}),
1135
869
  }
1136
870
 
1137
- // src/drivers/aws/driver.ts
1138
- function readPinnedInstanceId(stackName) {
1139
- try {
1140
- const raw = readFileSync(join(process.cwd(), ".ts-cloud/state", `${stackName}.json`), "utf8");
1141
- const state = JSON.parse(raw);
1142
- return typeof state.instanceId === "string" && state.instanceId.length > 0 ? state.instanceId : null;
1143
- } catch {
1144
- return null;
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);
1145
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
+ ];
1146
1013
  }
1147
1014
 
1148
- class AwsDriver {
1149
- name = "aws";
1150
- usesCloudFormation = true;
1151
- region;
1152
- constructor(options = {}) {
1153
- this.region = options.region || "us-east-1";
1154
- }
1155
- resolveRegion(config) {
1156
- return config.project.region || this.region;
1157
- }
1158
- async provisionComputeInfrastructure(options) {
1159
- const { config, environment } = options;
1160
- const region = this.resolveRegion(config);
1161
- const slug = config.project.slug;
1162
- const compute = config.infrastructure?.compute;
1163
- if (!compute)
1164
- throw new Error("infrastructure.compute is required to provision AWS compute");
1165
- const ec2 = new EC2Client(region);
1166
- const existing = await this.findComputeTargets({ slug, environment, role: "app" });
1167
- if (existing.length > 0) {
1168
- const first = existing[0];
1169
- return { appInstanceId: first.id, appPublicIp: first.publicIp, sshUser: "ubuntu", deployStoragePath: "/var/ts-cloud/staging" };
1170
- }
1171
- let imageId = resolveAwsImageId(config);
1172
- if (!imageId) {
1173
- const ssm = new SSMClient(region);
1174
- const param = await ssm.getParameter({ Name: UBUNTU_AMI_SSM_PARAM });
1175
- imageId = param.Parameter?.Value ?? null;
1176
- if (!imageId)
1177
- throw new Error("Could not resolve the Ubuntu 24.04 AMI from SSM");
1178
- }
1179
- const vpcs = await ec2.describeVpcs();
1180
- const vpc = (vpcs.Vpcs || []).find((v) => v.IsDefault) || (vpcs.Vpcs || [])[0];
1181
- if (!vpc?.VpcId)
1182
- throw new Error("No VPC found to launch the instance into");
1183
- const subnets = (await ec2.describeSubnets({ Filters: [{ Name: "vpc-id", Values: [vpc.VpcId] }] })).Subnets || [];
1184
- const subnet = subnets.find((s) => s.MapPublicIpOnLaunch) || subnets[0];
1185
- const subnetId = subnet?.SubnetId;
1186
- if (!subnet?.MapPublicIpOnLaunch)
1187
- console.warn("ts-cloud: no public subnet found; the instance may not get a public IP (deploys/SSL need one).");
1188
- const sgName = `${slug}-${environment}-app-sg`;
1189
- const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }, { Name: "vpc-id", Values: [vpc.VpcId] }] });
1190
- let groupId = found.SecurityGroups?.[0]?.GroupId;
1191
- if (!groupId) {
1192
- const created = await ec2.createSecurityGroup({ GroupName: sgName, Description: `ts-cloud ${slug}/${environment} app`, VpcId: vpc.VpcId });
1193
- groupId = created.GroupId;
1194
- }
1195
- const rules = awsComputeIngressRules(config);
1196
- await ec2.authorizeSecurityGroupIngress({
1197
- GroupId: groupId,
1198
- IpPermissions: rules.map((r) => ({ IpProtocol: r.protocol, FromPort: r.port, ToPort: r.port, IpRanges: [{ CidrIp: r.cidr }] }))
1199
- }).catch((e) => {
1200
- if (!/InvalidPermission\.Duplicate/.test(e instanceof Error ? e.message : ""))
1201
- throw e;
1202
- });
1203
- const userData = encodeUserData(buildAwsUserData(config));
1204
- const instanceType = compute.server?.instanceType || "t3.small";
1205
- const run = await ec2.runInstances({
1206
- ImageId: imageId,
1207
- InstanceType: instanceType,
1208
- MinCount: 1,
1209
- MaxCount: 1,
1210
- SecurityGroupIds: groupId ? [groupId] : undefined,
1211
- SubnetId: subnetId,
1212
- UserData: userData,
1213
- IamInstanceProfile: compute.server?.iamInstanceProfile ? { Name: compute.server.iamInstanceProfile } : undefined,
1214
- TagSpecifications: [{
1215
- ResourceType: "instance",
1216
- Tags: [
1217
- { Key: "Name", Value: `${slug}-${environment}-app` },
1218
- { Key: "Project", Value: slug },
1219
- { Key: "Environment", Value: environment },
1220
- { Key: "Role", Value: "app" }
1221
- ]
1222
- }]
1223
- });
1224
- const instanceId = run.Instances?.[0]?.InstanceId;
1225
- if (!instanceId)
1226
- throw new Error("RunInstances did not return an instance id");
1227
- const running = await ec2.waitForInstanceState(instanceId, "running");
1228
- if (!running)
1229
- throw new Error(`Instance ${instanceId} did not reach 'running' before timeout`);
1230
- return {
1231
- appInstanceId: instanceId,
1232
- appPublicIp: running.PublicIpAddress,
1233
- sshUser: "ubuntu",
1234
- deployStoragePath: "/var/ts-cloud/staging"
1235
- };
1015
+ // src/drivers/shared/ufw.ts
1016
+ var UFW_BASE_PORTS = [80, 443];
1017
+ function buildUfwScript(firewall = {}) {
1018
+ if (firewall.enabled === false)
1019
+ return [];
1020
+ const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => p > 0).sort((a, b) => a - b);
1021
+ const lines = [
1022
+ "export DEBIAN_FRONTEND=noninteractive",
1023
+ "apt-get install -y ufw",
1024
+ "ufw default deny incoming",
1025
+ "ufw default allow outgoing",
1026
+ "ufw allow OpenSSH"
1027
+ ];
1028
+ for (const port of ports)
1029
+ lines.push(`ufw allow ${port}/tcp`);
1030
+ lines.push("ufw --force enable");
1031
+ return lines;
1032
+ }
1033
+
1034
+ // src/drivers/shared/maintenance.ts
1035
+ function buildAutoUpdatesScript(enabled2 = true) {
1036
+ if (!enabled2)
1037
+ return [];
1038
+ return [
1039
+ "export DEBIAN_FRONTEND=noninteractive",
1040
+ "apt-get install -y unattended-upgrades",
1041
+ "cat > /etc/apt/apt.conf.d/20auto-upgrades <<'TS_CLOUD_AUTOUPD_EOF'",
1042
+ 'APT::Periodic::Update-Package-Lists "1";',
1043
+ 'APT::Periodic::Unattended-Upgrade "1";',
1044
+ 'APT::Periodic::Download-Upgradeable-Packages "1";',
1045
+ 'APT::Periodic::AutocleanInterval "7";',
1046
+ "TS_CLOUD_AUTOUPD_EOF",
1047
+ "systemctl enable unattended-upgrades 2>/dev/null || true",
1048
+ "systemctl restart unattended-upgrades 2>/dev/null || true"
1049
+ ];
1050
+ }
1051
+
1052
+ // src/drivers/shared/monitoring.ts
1053
+ var METRICS_PATH = "/var/lib/ts-cloud/metrics.json";
1054
+ var ALERT_STATE_PATH = "/var/lib/ts-cloud/alert-state";
1055
+ var DEFAULT_CPU_LOAD_PER_CORE = 2;
1056
+ var DEFAULT_MEM_PERCENT = 90;
1057
+ var DEFAULT_DISK_PERCENT = 90;
1058
+ var SERVICE_PROBES = [
1059
+ ["nginx", 80],
1060
+ ["phpFpm", 9074],
1061
+ ["mysql", 3306],
1062
+ ["postgres", 5432],
1063
+ ["redis", 6379],
1064
+ ["meilisearch", 7700]
1065
+ ];
1066
+ function resolveMonitoring(monitoring = true) {
1067
+ const obj = typeof monitoring === "object" ? monitoring : {};
1068
+ const enabled2 = typeof monitoring === "boolean" ? monitoring : monitoring.enabled !== false;
1069
+ return {
1070
+ enabled: enabled2,
1071
+ cpuLoadPerCore: obj.alerts?.cpuLoadPerCore ?? DEFAULT_CPU_LOAD_PER_CORE,
1072
+ memPercent: obj.alerts?.memPercent ?? DEFAULT_MEM_PERCENT,
1073
+ diskPercent: obj.alerts?.diskPercent ?? DEFAULT_DISK_PERCENT
1074
+ };
1075
+ }
1076
+ function buildMonitoringScript(monitoring = true) {
1077
+ const { enabled: enabled2, cpuLoadPerCore, memPercent, diskPercent } = resolveMonitoring(monitoring);
1078
+ if (!enabled2)
1079
+ return [];
1080
+ const probeLines = SERVICE_PROBES.map(([name, port]) => `SVC_${name.toUpperCase()}=$(probe ${port})`);
1081
+ const servicesJson = SERVICE_PROBES.map(([name]) => `"${name}":"$SVC_${name.toUpperCase()}"`).join(",");
1082
+ return [
1083
+ "mkdir -p /var/lib/ts-cloud",
1084
+ "cat > /usr/local/bin/ts-cloud-metrics.sh <<'TS_CLOUD_METRICS_EOF'",
1085
+ "#!/bin/bash",
1086
+ "set -uo pipefail",
1087
+ "LOAD=$(cut -d' ' -f1 /proc/loadavg)",
1088
+ "CPUS=$(nproc)",
1089
+ "MEM_TOTAL=$(free -m | awk '/^Mem:/{print $2}')",
1090
+ "MEM_USED=$(free -m | awk '/^Mem:/{print $3}')",
1091
+ "SWAP_TOTAL=$(free -m | awk '/^Swap:/{print $2}')",
1092
+ "SWAP_USED=$(free -m | awk '/^Swap:/{print $3}')",
1093
+ `DISK_PCT=$(df -P / | awk 'NR==2{gsub("%","",$5); print $5}')`,
1094
+ "UPTIME_SEC=$(cut -d' ' -f1 /proc/uptime | cut -d. -f1)",
1095
+ `RX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{rx+=$3} END{print rx+0}' /proc/net/dev)`,
1096
+ `TX_BYTES=$(awk -F'[: ]+' 'NR>2 && $2!="lo"{tx+=$11} END{print tx+0}' /proc/net/dev)`,
1097
+ "probe(){ (exec 3<>/dev/tcp/127.0.0.1/$1) 2>/dev/null && echo up || echo down; }",
1098
+ ...probeLines,
1099
+ "LOAD=${LOAD:-0}; CPUS=${CPUS:-1}; MEM_TOTAL=${MEM_TOTAL:-0}; MEM_USED=${MEM_USED:-0}",
1100
+ "SWAP_TOTAL=${SWAP_TOTAL:-0}; SWAP_USED=${SWAP_USED:-0}; DISK_PCT=${DISK_PCT:-0}",
1101
+ "UPTIME_SEC=${UPTIME_SEC:-0}; RX_BYTES=${RX_BYTES:-0}; TX_BYTES=${TX_BYTES:-0}",
1102
+ "MEM_PCT=$(( MEM_TOTAL > 0 ? MEM_USED * 100 / MEM_TOTAL : 0 ))",
1103
+ `cat > ${METRICS_PATH}.tmp <<JSON`,
1104
+ '{"load":$LOAD,"cpus":$CPUS,"memTotalMb":$MEM_TOTAL,"memUsedMb":$MEM_USED,"memUsedPct":$MEM_PCT,"swapTotalMb":$SWAP_TOTAL,"swapUsedMb":$SWAP_USED,"diskUsedPct":$DISK_PCT,"uptimeSec":$UPTIME_SEC,"network":{"rxBytes":$RX_BYTES,"txBytes":$TX_BYTES},"services":{' + servicesJson + "}}",
1105
+ "JSON",
1106
+ `mv -f ${METRICS_PATH}.tmp ${METRICS_PATH}`,
1107
+ 'ALERTS=""',
1108
+ `if awk -v l="$LOAD" -v c="$CPUS" -v t=${cpuLoadPerCore} 'BEGIN{exit !(c>0 && l/c > t)}'; then ALERTS="$ALERTS load=$LOAD/${cpuLoadPerCore}xCPU"; fi`,
1109
+ `if [ "\${MEM_PCT:-0}" -ge ${memPercent} ]; then ALERTS="$ALERTS mem=\${MEM_PCT}%"; fi`,
1110
+ `if [ "\${DISK_PCT:-0}" -ge ${diskPercent} ]; then ALERTS="$ALERTS disk=\${DISK_PCT}%"; fi`,
1111
+ `PREV=$(cat ${ALERT_STATE_PATH} 2>/dev/null || echo ok)`,
1112
+ 'if [ -n "$ALERTS" ]; then',
1113
+ ' if [ "$PREV" != alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "⚠️ $(hostname): resource alert —$ALERTS" || true; fi',
1114
+ ` echo alert > ${ALERT_STATE_PATH}`,
1115
+ "else",
1116
+ ' if [ "$PREV" = alert ] && [ -x /usr/local/bin/ts-cloud-notify ]; then /usr/local/bin/ts-cloud-notify "✅ $(hostname): resource usage back to normal" || true; fi',
1117
+ ` echo ok > ${ALERT_STATE_PATH}`,
1118
+ "fi",
1119
+ "TS_CLOUD_METRICS_EOF",
1120
+ "chmod +x /usr/local/bin/ts-cloud-metrics.sh",
1121
+ "cat > /etc/systemd/system/ts-cloud-metrics.service <<'TS_CLOUD_METRICS_SVC_EOF'",
1122
+ "[Unit]",
1123
+ "Description=ts-cloud metrics collector",
1124
+ "",
1125
+ "[Service]",
1126
+ "Type=oneshot",
1127
+ "ExecStart=/usr/local/bin/ts-cloud-metrics.sh",
1128
+ "TS_CLOUD_METRICS_SVC_EOF",
1129
+ "cat > /etc/systemd/system/ts-cloud-metrics.timer <<'TS_CLOUD_METRICS_TMR_EOF'",
1130
+ "[Unit]",
1131
+ "Description=Run ts-cloud metrics collector every minute",
1132
+ "",
1133
+ "[Timer]",
1134
+ "OnBootSec=60",
1135
+ "OnUnitActiveSec=60",
1136
+ "",
1137
+ "[Install]",
1138
+ "WantedBy=timers.target",
1139
+ "TS_CLOUD_METRICS_TMR_EOF",
1140
+ "systemctl daemon-reload",
1141
+ "systemctl enable ts-cloud-metrics.timer",
1142
+ "systemctl start ts-cloud-metrics.timer"
1143
+ ];
1144
+ }
1145
+
1146
+ // src/drivers/shared/ssh-keys.ts
1147
+ var BLOCK_BEGIN = "# >>> ts-cloud managed keys >>>";
1148
+ var BLOCK_END = "# <<< ts-cloud managed keys <<<";
1149
+ var DEFAULT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
1150
+ function buildAuthorizedKeysScript(keys = [], options = {}) {
1151
+ if (keys.length === 0)
1152
+ return [];
1153
+ const path = options.path ?? DEFAULT_AUTHORIZED_KEYS;
1154
+ const dir = path.replace(/\/[^/]*$/, "");
1155
+ const block = [
1156
+ BLOCK_BEGIN,
1157
+ ...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
1158
+ BLOCK_END
1159
+ ].join(`
1160
+ `);
1161
+ return [
1162
+ `mkdir -p ${dir}`,
1163
+ `touch ${path}`,
1164
+ `sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}`,
1165
+ `cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`,
1166
+ block,
1167
+ "TS_CLOUD_KEYS_EOF",
1168
+ `chmod 600 ${path}`
1169
+ ];
1170
+ }
1171
+ function escapeSed(value) {
1172
+ return value.replace(/[.*[\]\\/^$]/g, "\\$&");
1173
+ }
1174
+
1175
+ // src/drivers/shared/notifications.ts
1176
+ function wantsEvent(config, event) {
1177
+ return !config.events || config.events.includes(event);
1178
+ }
1179
+ async function sendNotifications(config, event, message, options = {}) {
1180
+ if (!config)
1181
+ return [];
1182
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1183
+ const attempted = [];
1184
+ const tasks = [];
1185
+ const post = (url, body) => fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).catch(() => {
1186
+ return;
1187
+ });
1188
+ if (config.slack?.webhookUrl && wantsEvent(config, event)) {
1189
+ attempted.push("slack");
1190
+ tasks.push(post(config.slack.webhookUrl, { text: message }));
1236
1191
  }
1237
- async destroyCompute(options) {
1238
- const { config, environment } = options;
1239
- const region = this.resolveRegion(config);
1240
- const ec2 = new EC2Client(region);
1241
- const destroyed = [];
1242
- const targets = await this.findComputeTargets({ slug: config.project.slug, environment, role: "app" });
1243
- const ids = targets.map((t) => t.id);
1244
- if (ids.length > 0) {
1245
- await ec2.terminateInstances(ids);
1246
- destroyed.push(...ids.map((id) => `instance ${id}`));
1247
- await Promise.all(ids.map((id) => ec2.waitForInstanceState(id, "terminated").catch(() => {
1192
+ if (config.discord?.webhookUrl && wantsEvent(config, event)) {
1193
+ attempted.push("discord");
1194
+ tasks.push(post(config.discord.webhookUrl, { content: message }));
1195
+ }
1196
+ if (config.telegram?.botToken && config.telegram.chatId && wantsEvent(config, event)) {
1197
+ attempted.push("telegram");
1198
+ const url = `https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`;
1199
+ tasks.push(post(url, { chat_id: config.telegram.chatId, text: message }));
1200
+ }
1201
+ if (config.webhook?.url && wantsEvent(config, event)) {
1202
+ attempted.push("webhook");
1203
+ if ((config.webhook.method || "POST") === "GET") {
1204
+ const sep = config.webhook.url.includes("?") ? "&" : "?";
1205
+ const url = `${config.webhook.url}${sep}event=${encodeURIComponent(event)}&message=${encodeURIComponent(message)}`;
1206
+ tasks.push(fetchImpl(url, { method: "GET" }).catch(() => {
1248
1207
  return;
1249
- })));
1250
- }
1251
- const sgName = `${config.project.slug}-${environment}-app-sg`;
1252
- const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }] }).catch(() => ({ SecurityGroups: [] }));
1253
- const groupId = found.SecurityGroups?.[0]?.GroupId;
1254
- if (groupId) {
1255
- for (let i = 0;i < 10; i++) {
1256
- try {
1257
- await ec2.deleteSecurityGroup(groupId);
1258
- destroyed.push(`security group ${sgName}`);
1259
- break;
1260
- } catch {
1261
- await new Promise((r) => setTimeout(r, 3000));
1262
- }
1263
- }
1208
+ }));
1209
+ } else {
1210
+ tasks.push(post(config.webhook.url, { event, message }));
1264
1211
  }
1265
- return { destroyed };
1266
1212
  }
1267
- async getComputeOutputs(options) {
1268
- const region = this.resolveRegion(options.config);
1269
- const stackName = resolveProjectStackName(options.config, options.environment);
1270
- const cfn = new CloudFormationClient(region);
1271
- try {
1272
- const outputs = await cfn.getStackOutputs(stackName);
1273
- return {
1274
- deployBucketName: outputs.deployBucketName,
1275
- appInstanceId: outputs.appInstanceId,
1276
- appPublicIp: outputs.appPublicIp,
1277
- sshUser: "ec2-user"
1278
- };
1279
- } catch (err) {
1280
- if (!/does not exist|ValidationError/i.test(err instanceof Error ? err.message : ""))
1281
- throw err;
1282
- const targets = await this.findComputeTargets({
1283
- slug: options.config.project.slug,
1284
- environment: options.environment,
1285
- role: "app"
1286
- });
1287
- const first = targets[0];
1288
- return {
1289
- appInstanceId: first?.id,
1290
- appPublicIp: first?.publicIp,
1291
- sshUser: "ubuntu",
1292
- deployStoragePath: "/var/ts-cloud/staging"
1293
- };
1294
- }
1213
+ if (config.email?.to && wantsEvent(config, event)) {
1214
+ attempted.push("email");
1215
+ tasks.push(sendEmailNotification(config.email, event, message));
1295
1216
  }
1296
- async uploadRelease(options) {
1297
- const region = this.resolveRegion(options.config);
1298
- const outputs = await this.getComputeOutputs({
1299
- config: options.config,
1300
- environment: options.environment
1301
- });
1302
- const bucket = outputs.deployBucketName;
1303
- if (!bucket) {
1304
- throw new Error("No deployBucketName in stack outputs. Re-deploy infrastructure to add the staging bucket.");
1305
- }
1306
- const s3 = new S3Client(region);
1307
- await s3.putObject({
1308
- bucket,
1309
- key: options.remoteKey,
1310
- body: readFileSync(options.localPath),
1311
- contentType: "application/gzip"
1217
+ await Promise.all(tasks);
1218
+ return attempted;
1219
+ }
1220
+ async function sendEmailNotification(email, event, message) {
1221
+ try {
1222
+ const mod = await import("./chunk-pqfzdg68.js");
1223
+ await mod.email.send({
1224
+ to: email.to,
1225
+ from: email.from,
1226
+ subject: `[ts-cloud] ${event}`,
1227
+ text: message
1312
1228
  });
1313
- return { artifactRef: `s3://${bucket}/${options.remoteKey}` };
1229
+ } catch {}
1230
+ }
1231
+ function resolveNotifications(project, site) {
1232
+ return site ?? project;
1233
+ }
1234
+ function buildNotifierScript(config) {
1235
+ if (!config)
1236
+ return [];
1237
+ const curls = [];
1238
+ if (config.slack?.webhookUrl)
1239
+ curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"text\\":\\"$MSG\\"}" ${config.slack.webhookUrl} || true`);
1240
+ if (config.discord?.webhookUrl)
1241
+ curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"content\\":\\"$MSG\\"}" ${config.discord.webhookUrl} || true`);
1242
+ if (config.telegram?.botToken && config.telegram.chatId)
1243
+ curls.push(`curl -fsS -X POST "https://api.telegram.org/bot${config.telegram.botToken}/sendMessage" --data-urlencode "chat_id=${config.telegram.chatId}" --data-urlencode "text=$MSG" || true`);
1244
+ if (config.webhook?.url)
1245
+ curls.push(`curl -fsS -X POST -H 'Content-Type: application/json' -d "{\\"message\\":\\"$MSG\\"}" ${config.webhook.url} || true`);
1246
+ if (curls.length === 0)
1247
+ return [];
1248
+ return [
1249
+ "cat > /usr/local/bin/ts-cloud-notify <<'TS_CLOUD_NOTIFY_EOF'",
1250
+ "#!/bin/bash",
1251
+ 'MSG="$1"',
1252
+ ...curls,
1253
+ "TS_CLOUD_NOTIFY_EOF",
1254
+ "chmod +x /usr/local/bin/ts-cloud-notify"
1255
+ ];
1256
+ }
1257
+
1258
+ // src/drivers/shared/backups.ts
1259
+ var BACKUP_OUTPUT_DIR = "/var/backups/ts-cloud";
1260
+ var BACKUP_CONFIG_PATH = "/etc/ts-cloud/backups.config.ts";
1261
+ var BACKUP_CRON_PATH = "/etc/cron.d/ts-cloud-backups";
1262
+ var BACKUP_RUNNER_PATH = "/usr/local/bin/ts-cloud-backup.sh";
1263
+ function backupEntryFor(database) {
1264
+ if (!database.name)
1265
+ return null;
1266
+ const host = database.host || "127.0.0.1";
1267
+ const isPostgres = database.engine === "postgres";
1268
+ const type = isPostgres ? "postgresql" : "mysql";
1269
+ const port = database.port ?? (isPostgres ? 5432 : 3306);
1270
+ const ts = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1271
+ return [
1272
+ " {",
1273
+ ` type: '${type}',`,
1274
+ ` name: '${ts(database.name)}',`,
1275
+ " connection: {",
1276
+ ` hostname: '${ts(host)}',`,
1277
+ ` port: ${port},`,
1278
+ ` database: '${ts(database.name)}',`,
1279
+ ` username: '${ts(database.username || database.name)}',`,
1280
+ ` password: '${ts(database.password || "")}',`,
1281
+ " ssl: false,",
1282
+ " },",
1283
+ " includeSchema: true,",
1284
+ " includeData: true,",
1285
+ " },"
1286
+ ].join(`
1287
+ `);
1288
+ }
1289
+ function buildBackupsConfigTs(database, backups) {
1290
+ const entry = database ? backupEntryFor(database) : null;
1291
+ const destinations = backups.bucket ? [
1292
+ " destinations: [",
1293
+ " {",
1294
+ " type: 's3',",
1295
+ ` bucket: '${backups.bucket}',`,
1296
+ " prefix: 'db-backups',",
1297
+ ...backups.endpoint ? [` endpoint: '${backups.endpoint}',`] : [],
1298
+ " optional: false,",
1299
+ " },",
1300
+ " ],"
1301
+ ] : [];
1302
+ return [
1303
+ "import type { BackupConfig } from 'ts-backups'",
1304
+ "",
1305
+ "const config: BackupConfig = {",
1306
+ " verbose: true,",
1307
+ ` outputPath: '${BACKUP_OUTPUT_DIR}',`,
1308
+ " retention: {",
1309
+ ` count: ${backups.retentionCount ?? 5},`,
1310
+ ` maxAge: ${backups.retentionDays ?? 30},`,
1311
+ " },",
1312
+ " databases: [",
1313
+ ...entry ? [entry] : [],
1314
+ " ],",
1315
+ ...destinations,
1316
+ "}",
1317
+ "",
1318
+ "export default config",
1319
+ ""
1320
+ ].join(`
1321
+ `);
1322
+ }
1323
+ function buildBackupProvisionScript(options) {
1324
+ const { database, backups } = options;
1325
+ if (!backups.enabled)
1326
+ return [];
1327
+ const schedule = backups.schedule || "0 2 * * *";
1328
+ const configTs = buildBackupsConfigTs(database, backups);
1329
+ return [
1330
+ "export DEBIAN_FRONTEND=noninteractive",
1331
+ `mkdir -p /etc/ts-cloud ${BACKUP_OUTPUT_DIR}`,
1332
+ "command -v bun >/dev/null 2>&1 || (curl -fsSL https://bun.sh/install | bash && ln -sf /root/.bun/bin/bun /usr/local/bin/bun)",
1333
+ "bun add -g ts-backups || true",
1334
+ `cat > ${BACKUP_CONFIG_PATH} <<'TS_CLOUD_BACKUP_CFG_EOF'`,
1335
+ configTs.replace(/\n$/, ""),
1336
+ "TS_CLOUD_BACKUP_CFG_EOF",
1337
+ `cat > ${BACKUP_RUNNER_PATH} <<'TS_CLOUD_BACKUP_RUN_EOF'`,
1338
+ "#!/bin/bash",
1339
+ "set -uo pipefail",
1340
+ 'export PATH="/root/.bun/bin:/usr/local/bin:$PATH"',
1341
+ 'notify() { [ -x /usr/local/bin/ts-cloud-notify ] && /usr/local/bin/ts-cloud-notify "$1" || true; }',
1342
+ "cd /etc/ts-cloud",
1343
+ 'if ! ts-backups backup --config /etc/ts-cloud/backups.config.ts; then notify "❌ ts-cloud backup failed"; exit 1; fi',
1344
+ "TS_CLOUD_BACKUP_RUN_EOF",
1345
+ `chmod +x ${BACKUP_RUNNER_PATH}`,
1346
+ `cat > ${BACKUP_CRON_PATH} <<'TS_CLOUD_BACKUP_CRON_EOF'`,
1347
+ `${schedule} root ${BACKUP_RUNNER_PATH} >> /var/log/ts-cloud-backup.log 2>&1`,
1348
+ "TS_CLOUD_BACKUP_CRON_EOF",
1349
+ `chmod 644 ${BACKUP_CRON_PATH}`
1350
+ ];
1351
+ }
1352
+ function engineSocket(engine) {
1353
+ return engine === "mariadb" ? "/var/lib/pantry/mariadb/mariadbd.sock" : "/var/lib/pantry/mysql/mysqld.sock";
1354
+ }
1355
+ function buildBackupRestoreScript(database, options = {}) {
1356
+ if (!database?.name)
1357
+ return [];
1358
+ const name = database.name;
1359
+ const isPg = database.engine === "postgres";
1360
+ const locate = options.from ? `TS_CLOUD_DUMP="${options.from}"` : `TS_CLOUD_DUMP="$(find ${BACKUP_OUTPUT_DIR} -type f -name '*${name}*.sql' -o -type f -name '*${name}*.sql.gz' 2>/dev/null | xargs -r ls -1t 2>/dev/null | head -1)"`;
1361
+ const client = isPg ? `psql -h 127.0.0.1 -p ${database.port ?? 5432} -U postgres -d "${name}"` : `mysql --socket=${engineSocket(database.engine)} -u root "${name}"`;
1362
+ return [
1363
+ "set -uo pipefail",
1364
+ 'eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true',
1365
+ locate,
1366
+ '[ -n "$TS_CLOUD_DUMP" ] && [ -f "$TS_CLOUD_DUMP" ] || { echo "no backup dump found to restore" >&2; exit 1; }',
1367
+ 'echo "[ts-cloud] restoring ' + name + ' from $TS_CLOUD_DUMP"',
1368
+ `case "$TS_CLOUD_DUMP" in *.gz) gunzip -c "$TS_CLOUD_DUMP" ;; *) cat "$TS_CLOUD_DUMP" ;; esac | ${client}`,
1369
+ 'echo "restore complete"'
1370
+ ];
1371
+ }
1372
+
1373
+ // src/drivers/shared/compute-provision.ts
1374
+ function buildComputeProvisionScripts(config) {
1375
+ const compute = config.infrastructure?.compute ?? {};
1376
+ const phpBox = compute.runtime === "php" || !!compute.php;
1377
+ const needsPantry = phpBox || !!compute.managedServices;
1378
+ const pantryBootstrap = needsPantry ? buildPantryBootstrapScript() : [];
1379
+ const useNginx = !usesRpxProxy(compute);
1380
+ const phpProvision = phpBox ? [
1381
+ ...pantryBootstrap,
1382
+ ...buildPhpProvisionScript({
1383
+ versions: compute.php?.versions,
1384
+ default: compute.php?.default,
1385
+ extensions: compute.php?.extensions,
1386
+ installNginx: useNginx,
1387
+ optimizeForProduction: compute.php?.optimizeForProduction,
1388
+ ini: compute.php?.ini
1389
+ }),
1390
+ ...useNginx ? buildNginxServiceScript() : []
1391
+ ] : undefined;
1392
+ const extras = [];
1393
+ if (!phpBox && needsPantry)
1394
+ extras.push(...pantryBootstrap);
1395
+ extras.push(...buildNotifierScript(config.notifications));
1396
+ if (compute.managedServices) {
1397
+ extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices));
1314
1398
  }
1315
- async findComputeTargets(options) {
1316
- const region = this.region;
1317
- const ec2 = new EC2Client(region);
1318
- const filters = [
1319
- { Name: "tag:Project", Values: [options.slug] },
1320
- { Name: "tag:Environment", Values: [options.environment] },
1321
- { Name: "tag:Role", Values: [options.role || "app"] },
1322
- { Name: "instance-state-name", Values: ["running", "pending"] }
1323
- ];
1324
- const result = await ec2.describeInstances({ Filters: filters });
1325
- const targets = this.reservationsToTargets(result.Reservations);
1326
- if (targets.length > 0 || (options.role || "app") !== "app")
1327
- return targets;
1328
- const stackName = options.stackName ?? `${options.slug}-${options.environment}`;
1329
- let pinnedId = readPinnedInstanceId(stackName);
1330
- if (!pinnedId) {
1331
- try {
1332
- pinnedId = (await new CloudFormationClient(region).getStackOutputs(stackName)).appInstanceId ?? null;
1333
- } catch {
1334
- pinnedId = null;
1335
- }
1336
- }
1337
- if (!pinnedId)
1338
- return [];
1339
- try {
1340
- const pinned = await ec2.describeInstances({
1341
- InstanceIds: [pinnedId],
1342
- Filters: [{ Name: "instance-state-name", Values: ["running", "pending"] }]
1343
- });
1344
- return this.reservationsToTargets(pinned.Reservations);
1345
- } catch {
1346
- return [];
1347
- }
1399
+ extras.push(...buildUfwScript(compute.firewall ?? (phpBox ? { enabled: true } : { enabled: false })));
1400
+ extras.push(...buildAutoUpdatesScript(compute.autoUpdates ?? phpBox));
1401
+ extras.push(...buildMonitoringScript(compute.monitoring ?? phpBox));
1402
+ extras.push(...buildAuthorizedKeysScript(compute.sshKeys));
1403
+ if (compute.backups?.enabled) {
1404
+ extras.push(...buildBackupProvisionScript({
1405
+ database: config.infrastructure?.appDatabase,
1406
+ backups: compute.backups
1407
+ }));
1348
1408
  }
1349
- reservationsToTargets(reservations) {
1350
- const targets = [];
1351
- for (const reservation of reservations || []) {
1352
- for (const instance of reservation.Instances || []) {
1353
- if (!instance.InstanceId)
1354
- continue;
1355
- const nameTag = instance.Tags?.find((tag) => tag.Key === "Name")?.Value;
1356
- targets.push({
1357
- id: instance.InstanceId,
1358
- name: nameTag,
1359
- publicIp: instance.PublicIpAddress,
1360
- privateIp: instance.PrivateIpAddress,
1361
- status: instance.State?.Name
1362
- });
1363
- }
1409
+ return {
1410
+ runtime: compute.runtime || "bun",
1411
+ runtimeVersion: compute.runtimeVersion || "latest",
1412
+ phpBox,
1413
+ phpProvision,
1414
+ servicesProvision: extras.length > 0 ? extras : undefined
1415
+ };
1416
+ }
1417
+
1418
+ // src/drivers/shared/ubuntu-bootstrap.ts
1419
+ function buildUbuntuBootstrapScript(options = {}) {
1420
+ const {
1421
+ runtime = "bun",
1422
+ runtimeVersion = "latest",
1423
+ systemPackages = [],
1424
+ database,
1425
+ phpProvision,
1426
+ servicesProvision,
1427
+ caddyfile,
1428
+ rpxProvision,
1429
+ baked = false
1430
+ } = options;
1431
+ const packages = new Set(systemPackages);
1432
+ if (database === "sqlite")
1433
+ packages.add("sqlite3");
1434
+ else if (database === "mysql")
1435
+ packages.add("mysql-client");
1436
+ else if (database === "postgres")
1437
+ packages.add("postgresql-client");
1438
+ let script = `#!/bin/bash
1439
+ set -euo pipefail
1440
+ `;
1441
+ if (!baked) {
1442
+ script += `
1443
+ export DEBIAN_FRONTEND=noninteractive
1444
+ apt-get update -y
1445
+ apt-get upgrade -y
1446
+ apt-get install -y curl tar gzip unzip git ca-certificates
1447
+ `;
1448
+ if (packages.size > 0) {
1449
+ script += `
1450
+ apt-get install -y ${[...packages].join(" ")}
1451
+ `;
1364
1452
  }
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);
1453
+ if (phpProvision && phpProvision.length > 0) {
1454
+ script += `
1455
+ ${phpProvision.join(`
1456
+ `)}
1457
+ `;
1386
1458
  }
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" };
1459
+ if (servicesProvision && servicesProvision.length > 0) {
1460
+ script += `
1461
+ ${servicesProvision.join(`
1462
+ `)}
1463
+ `;
1389
1464
  }
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 {}
1465
+ if (runtime === "bun") {
1466
+ script += `
1467
+ # cloud-init runs this with \`set -u\` and NO $HOME set. bun's install.sh
1468
+ # dereferences $HOME even when BUN_INSTALL is set, so without this the install
1469
+ # aborts with "HOME: unbound variable" and /usr/local/bin/bun never appears —
1470
+ # breaking a from-scratch provision (adopting an existing box masked it).
1471
+ export HOME="\${HOME:-/root}"
1472
+ export BUN_INSTALL="/root/.bun"
1473
+ # bun.sh's installer references $HOME internally and runs under this
1474
+ # script's \`set -u\` (inherited by the piped bash) cloud-init's runcmd
1475
+ # environment doesn't export HOME by default, so without this the
1476
+ # installer dies with "HOME: unbound variable" partway through (after
1477
+ # the binary is already downloaded, so it looks like a partial success)
1478
+ # and \`set -e\` aborts everything after it: the bun symlink, /var/www +
1479
+ # /var/ts-cloud dirs, and the whole rpx gateway install/systemd setup
1480
+ # never run. Confirmed against a real Hetzner box (stacksjs/status#1
1481
+ # Phase 9 e2e deploy) — cloud-init reported success with none of that
1482
+ # actually done.
1483
+ export HOME="\${HOME:-/root}"
1484
+ curl -fsSL https://bun.sh/install | bash${runtimeVersion === "latest" ? "" : ` -s "bun-v${runtimeVersion}"`}
1485
+ ln -sf /root/.bun/bin/bun /usr/local/bin/bun
1486
+ echo 'export BUN_INSTALL="/root/.bun"' > /etc/profile.d/bun.sh
1487
+ echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> /etc/profile.d/bun.sh
1488
+ `;
1489
+ } else if (runtime === "node") {
1490
+ const nodeMajor = runtimeVersion === "latest" || !runtimeVersion ? "20" : runtimeVersion.split(".")[0];
1491
+ script += `
1492
+ curl -fsSL https://deb.nodesource.com/setup_${nodeMajor}.x | bash -
1493
+ apt-get install -y nodejs
1494
+ ln -sf /usr/bin/node /usr/local/bin/node
1495
+ ln -sf /usr/bin/npm /usr/local/bin/npm
1496
+ `;
1497
+ } else if (runtime === "deno") {
1498
+ script += `
1499
+ curl -fsSL https://deno.land/install.sh | sh
1500
+ ln -sf /root/.deno/bin/deno /usr/local/bin/deno
1501
+ `;
1423
1502
  }
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
1503
  }
1438
- }
1504
+ script += `
1505
+ mkdir -p /var/www /var/ts-cloud/staging /var/ts-cloud/releases
1506
+ `;
1507
+ if (caddyfile) {
1508
+ const escaped = caddyfile.replace(/\$/g, "\\$");
1509
+ script += `
1510
+ ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
1511
+ curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=\${ARCH}" -o /usr/local/bin/caddy
1512
+ chmod +x /usr/local/bin/caddy
1439
1513
 
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";
1514
+ getent group caddy >/dev/null || groupadd --system caddy
1515
+ getent passwd caddy >/dev/null || useradd --system --gid caddy \\
1516
+ --create-home --home-dir /var/lib/caddy \\
1517
+ --shell /usr/sbin/nologin --comment "Caddy web server" caddy
1445
1518
 
1446
- // src/drivers/hetzner/client.ts
1447
- var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
1519
+ mkdir -p /etc/caddy /var/lib/caddy /var/log/caddy
1520
+ chown -R caddy:caddy /var/lib/caddy /var/log/caddy
1448
1521
 
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");
1613
- }
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
- }
1628
- throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
1522
+ cat > /etc/systemd/system/caddy.service <<'CADDY_UNIT_EOF'
1523
+ [Unit]
1524
+ Description=Caddy
1525
+ Documentation=https://caddyserver.com/docs/
1526
+ After=network.target network-online.target
1527
+ Requires=network-online.target
1528
+
1529
+ [Service]
1530
+ Type=notify
1531
+ User=caddy
1532
+ Group=caddy
1533
+ ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
1534
+ ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
1535
+ TimeoutStopSec=5s
1536
+ LimitNOFILE=1048576
1537
+ PrivateTmp=true
1538
+ ProtectSystem=full
1539
+ AmbientCapabilities=CAP_NET_BIND_SERVICE
1540
+
1541
+ [Install]
1542
+ WantedBy=multi-user.target
1543
+ CADDY_UNIT_EOF
1544
+
1545
+ cat > /etc/caddy/Caddyfile <<'CADDY_CONFIG_EOF'
1546
+ ${escaped}
1547
+ CADDY_CONFIG_EOF
1548
+
1549
+ systemctl daemon-reload
1550
+ systemctl enable caddy
1551
+ systemctl start caddy
1552
+ `;
1629
1553
  }
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.");
1554
+ if (rpxProvision && rpxProvision.length > 0) {
1555
+ const body = rpxProvision[0] === "set -euo pipefail" ? rpxProvision.slice(1) : rpxProvision;
1556
+ script += `
1557
+ ${body.join(`
1558
+ `)}
1559
+ `;
1635
1560
  }
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} ]
1561
+ script += `
1562
+ echo "ts-cloud bootstrap complete — instance is ready for site deploys"
1658
1563
  `;
1564
+ return script;
1659
1565
  }
1660
1566
 
1661
- // src/deploy/site-target.ts
1662
- function siteInstallBase(slug, siteName) {
1663
- return `/var/www/${slug}-${siteName}`;
1567
+ // src/drivers/aws/provision.ts
1568
+ var UBUNTU_AMI_SSM_PARAM = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id";
1569
+ function awsComputeIngressRules(config) {
1570
+ const rules = [
1571
+ { port: 22, protocol: "tcp", cidr: "0.0.0.0/0" },
1572
+ { port: 80, protocol: "tcp", cidr: "0.0.0.0/0" },
1573
+ { port: 443, protocol: "tcp", cidr: "0.0.0.0/0" }
1574
+ ];
1575
+ const extra = new Set;
1576
+ for (const site of Object.values(config.sites || {})) {
1577
+ if (site && typeof site.port === "number" && ![22, 80, 443].includes(site.port))
1578
+ extra.add(site.port);
1579
+ }
1580
+ for (const port of extra)
1581
+ rules.push({ port, protocol: "tcp", cidr: "0.0.0.0/0" });
1582
+ return rules;
1664
1583
  }
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);
1584
+ function buildAwsUserData(config) {
1585
+ const compute = config.infrastructure?.compute ?? {};
1586
+ const provision = buildComputeProvisionScripts(config);
1587
+ return buildUbuntuBootstrapScript({
1588
+ runtime: provision.runtime,
1589
+ runtimeVersion: provision.runtimeVersion,
1590
+ systemPackages: compute.systemPackages,
1591
+ database: config.infrastructure?.database,
1592
+ phpProvision: provision.phpProvision,
1593
+ servicesProvision: provision.servicesProvision,
1594
+ baked: compute.bakedImage === true
1595
+ });
1673
1596
  }
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";
1597
+ function encodeUserData(userData) {
1598
+ return Buffer.from(userData, "utf8").toString("base64");
1682
1599
  }
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";
1600
+ function resolveAwsImageId(config) {
1601
+ return config.infrastructure?.compute?.image ?? null;
1692
1602
  }
1693
- function hasComputeConfigured(config) {
1694
- return config.infrastructure?.compute != null;
1603
+
1604
+ // src/drivers/aws/driver.ts
1605
+ function readPinnedInstanceId(stackName) {
1606
+ try {
1607
+ const raw = readFileSync(join(process.cwd(), ".ts-cloud/state", `${stackName}.json`), "utf8");
1608
+ const state = JSON.parse(raw);
1609
+ return typeof state.instanceId === "string" && state.instanceId.length > 0 ? state.instanceId : null;
1610
+ } catch {
1611
+ return null;
1612
+ }
1695
1613
  }
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;
1614
+
1615
+ class AwsDriver {
1616
+ name = "aws";
1617
+ usesCloudFormation = true;
1618
+ region;
1619
+ constructor(options = {}) {
1620
+ this.region = options.region || "us-east-1";
1621
+ }
1622
+ resolveRegion(config) {
1623
+ return config.project.region || this.region;
1624
+ }
1625
+ async provisionComputeInfrastructure(options) {
1626
+ const { config, environment } = options;
1627
+ const region = this.resolveRegion(config);
1628
+ const slug = config.project.slug;
1629
+ const compute = config.infrastructure?.compute;
1630
+ if (!compute)
1631
+ throw new Error("infrastructure.compute is required to provision AWS compute");
1632
+ const ec2 = new EC2Client(region);
1633
+ const existing = await this.findComputeTargets({ slug, environment, role: "app" });
1634
+ if (existing.length > 0) {
1635
+ const first = existing[0];
1636
+ return { appInstanceId: first.id, appPublicIp: first.publicIp, sshUser: "ubuntu", deployStoragePath: "/var/ts-cloud/staging" };
1708
1637
  }
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;
1638
+ let imageId = resolveAwsImageId(config);
1639
+ if (!imageId) {
1640
+ const ssm = new SSMClient(region);
1641
+ const param = await ssm.getParameter({ Name: UBUNTU_AMI_SSM_PARAM });
1642
+ imageId = param.Parameter?.Value ?? null;
1643
+ if (!imageId)
1644
+ throw new Error("Could not resolve the Ubuntu 24.04 AMI from SSM");
1728
1645
  }
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;
1646
+ const vpcs = await ec2.describeVpcs();
1647
+ const vpc = (vpcs.Vpcs || []).find((v) => v.IsDefault) || (vpcs.Vpcs || [])[0];
1648
+ if (!vpc?.VpcId)
1649
+ throw new Error("No VPC found to launch the instance into");
1650
+ const subnets = (await ec2.describeSubnets({ Filters: [{ Name: "vpc-id", Values: [vpc.VpcId] }] })).Subnets || [];
1651
+ const subnet = subnets.find((s) => s.MapPublicIpOnLaunch) || subnets[0];
1652
+ const subnetId = subnet?.SubnetId;
1653
+ if (!subnet?.MapPublicIpOnLaunch)
1654
+ console.warn("ts-cloud: no public subnet found; the instance may not get a public IP (deploys/SSL need one).");
1655
+ const sgName = `${slug}-${environment}-app-sg`;
1656
+ const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }, { Name: "vpc-id", Values: [vpc.VpcId] }] });
1657
+ let groupId = found.SecurityGroups?.[0]?.GroupId;
1658
+ if (!groupId) {
1659
+ const created = await ec2.createSecurityGroup({ GroupName: sgName, Description: `ts-cloud ${slug}/${environment} app`, VpcId: vpc.VpcId });
1660
+ groupId = created.GroupId;
1661
+ }
1662
+ const rules = awsComputeIngressRules(config);
1663
+ await ec2.authorizeSecurityGroupIngress({
1664
+ GroupId: groupId,
1665
+ IpPermissions: rules.map((r) => ({ IpProtocol: r.protocol, FromPort: r.port, ToPort: r.port, IpRanges: [{ CidrIp: r.cidr }] }))
1666
+ }).catch((e) => {
1667
+ if (!/InvalidPermission\.Duplicate/.test(e instanceof Error ? e.message : ""))
1668
+ throw e;
1669
+ });
1670
+ const userData = encodeUserData(buildAwsUserData(config));
1671
+ const instanceType = compute.server?.instanceType || "t3.small";
1672
+ const run = await ec2.runInstances({
1673
+ ImageId: imageId,
1674
+ InstanceType: instanceType,
1675
+ MinCount: 1,
1676
+ MaxCount: 1,
1677
+ SecurityGroupIds: groupId ? [groupId] : undefined,
1678
+ SubnetId: subnetId,
1679
+ UserData: userData,
1680
+ IamInstanceProfile: compute.server?.iamInstanceProfile ? { Name: compute.server.iamInstanceProfile } : undefined,
1681
+ TagSpecifications: [{
1682
+ ResourceType: "instance",
1683
+ Tags: [
1684
+ { Key: "Name", Value: `${slug}-${environment}-app` },
1685
+ { Key: "Project", Value: slug },
1686
+ { Key: "Environment", Value: environment },
1687
+ { Key: "Role", Value: "app" }
1688
+ ]
1689
+ }]
1690
+ });
1691
+ const instanceId = run.Instances?.[0]?.InstanceId;
1692
+ if (!instanceId)
1693
+ throw new Error("RunInstances did not return an instance id");
1694
+ const running = await ec2.waitForInstanceState(instanceId, "running");
1695
+ if (!running)
1696
+ throw new Error(`Instance ${instanceId} did not reach 'running' before timeout`);
1697
+ return {
1698
+ appInstanceId: instanceId,
1699
+ appPublicIp: running.PublicIpAddress,
1700
+ sshUser: "ubuntu",
1701
+ deployStoragePath: "/var/ts-cloud/staging"
1702
+ };
1703
+ }
1704
+ async destroyCompute(options) {
1705
+ const { config, environment } = options;
1706
+ const region = this.resolveRegion(config);
1707
+ const ec2 = new EC2Client(region);
1708
+ const destroyed = [];
1709
+ const targets = await this.findComputeTargets({ slug: config.project.slug, environment, role: "app" });
1710
+ const ids = targets.map((t) => t.id);
1711
+ if (ids.length > 0) {
1712
+ await ec2.terminateInstances(ids);
1713
+ destroyed.push(...ids.map((id) => `instance ${id}`));
1714
+ await Promise.all(ids.map((id) => ec2.waitForInstanceState(id, "terminated").catch(() => {
1715
+ return;
1716
+ })));
1732
1717
  }
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);
1718
+ const sgName = `${config.project.slug}-${environment}-app-sg`;
1719
+ const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }] }).catch(() => ({ SecurityGroups: [] }));
1720
+ const groupId = found.SecurityGroups?.[0]?.GroupId;
1721
+ if (groupId) {
1722
+ for (let i = 0;i < 10; i++) {
1723
+ try {
1724
+ await ec2.deleteSecurityGroup(groupId);
1725
+ destroyed.push(`security group ${sgName}`);
1726
+ break;
1727
+ } catch {
1728
+ await new Promise((r) => setTimeout(r, 3000));
1750
1729
  }
1751
1730
  }
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
1731
  }
1732
+ return { destroyed };
1774
1733
  }
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}`);
1818
- }
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;
1734
+ async getComputeOutputs(options) {
1735
+ const region = this.resolveRegion(options.config);
1736
+ const stackName = resolveProjectStackName(options.config, options.environment);
1737
+ const cfn = new CloudFormationClient(region);
1738
+ try {
1739
+ const outputs = await cfn.getStackOutputs(stackName);
1740
+ return {
1741
+ deployBucketName: outputs.deployBucketName,
1742
+ appInstanceId: outputs.appInstanceId,
1743
+ appPublicIp: outputs.appPublicIp,
1744
+ sshUser: "ec2-user"
1745
+ };
1746
+ } catch (err) {
1747
+ if (!/does not exist|ValidationError/i.test(err instanceof Error ? err.message : ""))
1748
+ throw err;
1749
+ const targets = await this.findComputeTargets({
1750
+ slug: options.config.project.slug,
1751
+ environment: options.environment,
1752
+ role: "app"
1753
+ });
1754
+ const first = targets[0];
1755
+ return {
1756
+ appInstanceId: first?.id,
1757
+ appPublicIp: first?.publicIp,
1758
+ sshUser: "ubuntu",
1759
+ deployStoragePath: "/var/ts-cloud/staging"
1760
+ };
1839
1761
  }
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 } : {}
1762
+ }
1763
+ async uploadRelease(options) {
1764
+ const region = this.resolveRegion(options.config);
1765
+ const outputs = await this.getComputeOutputs({
1766
+ config: options.config,
1767
+ environment: options.environment
1768
+ });
1769
+ const bucket = outputs.deployBucketName;
1770
+ if (!bucket) {
1771
+ throw new Error("No deployBucketName in stack outputs. Re-deploy infrastructure to add the staging bucket.");
1772
+ }
1773
+ const s3 = new S3Client(region);
1774
+ await s3.putObject({
1775
+ bucket,
1776
+ key: options.remoteKey,
1777
+ body: readFileSync(options.localPath),
1778
+ contentType: "application/gzip"
1779
+ });
1780
+ return { artifactRef: `s3://${bucket}/${options.remoteKey}` };
1781
+ }
1782
+ async findComputeTargets(options) {
1783
+ const region = this.region;
1784
+ const ec2 = new EC2Client(region);
1785
+ const filters = [
1786
+ { Name: "tag:Project", Values: [options.slug] },
1787
+ { Name: "tag:Environment", Values: [options.environment] },
1788
+ { Name: "tag:Role", Values: [options.role || "app"] },
1789
+ { Name: "instance-state-name", Values: ["running", "pending"] }
1790
+ ];
1791
+ const result = await ec2.describeInstances({ Filters: filters });
1792
+ const targets = this.reservationsToTargets(result.Reservations);
1793
+ if (targets.length > 0 || (options.role || "app") !== "app")
1794
+ return targets;
1795
+ const stackName = options.stackName ?? `${options.slug}-${options.environment}`;
1796
+ let pinnedId = readPinnedInstanceId(stackName);
1797
+ if (!pinnedId) {
1798
+ try {
1799
+ pinnedId = (await new CloudFormationClient(region).getStackOutputs(stackName)).appInstanceId ?? null;
1800
+ } catch {
1801
+ pinnedId = null;
1802
+ }
1803
+ }
1804
+ if (!pinnedId)
1805
+ return [];
1806
+ try {
1807
+ const pinned = await ec2.describeInstances({
1808
+ InstanceIds: [pinnedId],
1809
+ Filters: [{ Name: "instance-state-name", Values: ["running", "pending"] }]
1851
1810
  });
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
1811
+ return this.reservationsToTargets(pinned.Reservations);
1812
+ } catch {
1813
+ return [];
1814
+ }
1815
+ }
1816
+ reservationsToTargets(reservations) {
1817
+ const targets = [];
1818
+ for (const reservation of reservations || []) {
1819
+ for (const instance of reservation.Instances || []) {
1820
+ if (!instance.InstanceId)
1821
+ continue;
1822
+ const nameTag = instance.Tags?.find((tag) => tag.Key === "Name")?.Value;
1823
+ targets.push({
1824
+ id: instance.InstanceId,
1825
+ name: nameTag,
1826
+ publicIp: instance.PublicIpAddress,
1827
+ privateIp: instance.PrivateIpAddress,
1828
+ status: instance.State?.Name
1829
+ });
1830
+ }
1831
+ }
1832
+ return targets;
1833
+ }
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
1861
1848
  });
1849
+ if (!sendResult.CommandId) {
1850
+ return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
1851
+ }
1852
+ return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
1862
1853
  }
1863
- domains.add(site.domain);
1864
- }
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);
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" };
1874
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
+ };
1875
1874
  }
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
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;
1888
+ }
1889
+ } catch {}
1890
+ }
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"
1894
1903
  };
1895
1904
  }
1896
- if (options.proxy.onDemandTls) {
1897
- config.acmeChallengeWebroot = options.proxy.acmeWebroot ?? DEFAULT_ACME_WEBROOT;
1905
+ }
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`);
1945
+ }
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}`);
1950
+ }
1951
+ return data;
1952
+ }
1953
+ async listServers() {
1954
+ const data = await this.request("GET", "/servers");
1955
+ return data.servers;
1956
+ }
1957
+ async getServer(id) {
1958
+ const data = await this.request("GET", `/servers/${id}`);
1959
+ return data.server;
1960
+ }
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
  });
@@ -4418,7 +4424,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4418
4424
  appBase: appBase2,
4419
4425
  defaultPhpVersion: phpVersion
4420
4426
  });
4421
- const useNginx = compute2?.webServer !== "rpx";
4427
+ const useNginx = !usesRpxProxy(compute2);
4422
4428
  const sslProvider = resolveSslProvider(site);
4423
4429
  const customCert = sslProvider === "custom" && site.ssl?.certPath && site.ssl?.keyPath ? { certPath: site.ssl.certPath, keyPath: site.ssl.keyPath } : undefined;
4424
4430
  const poolScript = site.isolation ? buildPhpFpmPoolScript({ siteName, appBase: appBase2 }) : [];
@@ -4500,7 +4506,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4500
4506
  healthCheckPath: site.healthCheck?.path
4501
4507
  });
4502
4508
  const compute = config.infrastructure?.compute;
4503
- const wantsNginxStatic = kind === "server-static" && compute?.webServer !== "rpx" && !!site.domain;
4509
+ const wantsNginxStatic = kind === "server-static" && !usesRpxProxy(compute) && !!site.domain;
4504
4510
  const staticVhost = wantsNginxStatic ? buildNginxVhostScript({
4505
4511
  siteName,
4506
4512
  domain: site.domain,
@@ -4761,4 +4767,4 @@ function buildCloudFrontOriginConfig(options) {
4761
4767
  CustomErrorResponses: { Quantity: 0 }
4762
4768
  };
4763
4769
  }
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 };
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 };