@stacksjs/ts-cloud 0.7.29 → 0.7.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/bin/cli.js +774 -767
  2. package/dist/{chunk-wv2ch2yb.js → chunk-0c69nbaa.js} +253 -101
  3. package/dist/{chunk-nrkrr2yw.js → chunk-crcjdm09.js} +8 -7
  4. package/dist/{chunk-4ew46pj6.js → chunk-ebx9gjnn.js} +2 -2
  5. package/dist/{chunk-k56wpkhg.js → chunk-stt1z5cx.js} +4 -1
  6. package/dist/{chunk-3mcfbrw8.js → chunk-tskj9fay.js} +1 -1
  7. package/dist/deploy/index.js +4 -4
  8. package/dist/drivers/hetzner/client.d.ts +8 -0
  9. package/dist/drivers/hetzner/driver.d.ts +16 -0
  10. package/dist/drivers/index.d.ts +3 -3
  11. package/dist/drivers/index.js +6 -2
  12. package/dist/drivers/local-box/driver.d.ts +1 -1
  13. package/dist/drivers/shared/compute-deploy.d.ts +9 -3
  14. package/dist/drivers/shared/deploy-script.d.ts +12 -2
  15. package/dist/drivers/shared/fleet.d.ts +12 -1
  16. package/dist/drivers/shared/rpx-gateway.d.ts +26 -0
  17. package/dist/drivers/shared/ssh-keys.d.ts +3 -2
  18. package/dist/index.js +6 -4
  19. package/dist/ui/index.html +3 -3
  20. package/dist/ui/server/actions.html +3 -3
  21. package/dist/ui/server/backups.html +3 -3
  22. package/dist/ui/server/database.html +3 -3
  23. package/dist/ui/server/deployments.html +3 -3
  24. package/dist/ui/server/firewall.html +3 -3
  25. package/dist/ui/server/logs.html +3 -3
  26. package/dist/ui/server/services.html +3 -3
  27. package/dist/ui/server/sites.html +3 -3
  28. package/dist/ui/server/ssh-keys.html +3 -3
  29. package/dist/ui/server/team.html +3 -3
  30. package/dist/ui/server/terminal.html +3 -3
  31. package/dist/ui/server/workers.html +3 -3
  32. package/dist/ui/serverless/alarms.html +3 -3
  33. package/dist/ui/serverless/assets.html +3 -3
  34. package/dist/ui/serverless/data.html +3 -3
  35. package/dist/ui/serverless/deployments.html +3 -3
  36. package/dist/ui/serverless/functions.html +3 -3
  37. package/dist/ui/serverless/logs.html +3 -3
  38. package/dist/ui/serverless/queues.html +3 -3
  39. package/dist/ui/serverless/scheduler.html +3 -3
  40. package/dist/ui/serverless/secrets.html +3 -3
  41. package/dist/ui/serverless/traces.html +3 -3
  42. package/dist/ui/serverless.html +3 -3
  43. package/package.json +3 -3
@@ -2,10 +2,11 @@ import {
2
2
  deploymentCoexistenceError,
3
3
  hasManagementDashboardSite,
4
4
  isManagementDashboardSiteName,
5
+ resolveAppDatabase,
5
6
  resolveCloudProvider,
6
7
  resolveManagementDashboardSites,
7
8
  resolveProjectStackName
8
- } from "./chunk-k56wpkhg.js";
9
+ } from "./chunk-stt1z5cx.js";
9
10
  import {
10
11
  EC2Client,
11
12
  SSMClient
@@ -145,7 +146,7 @@ function buildDatabaseSetupScript(database, services = {}) {
145
146
  const lines = [];
146
147
  for (const db of dbs) {
147
148
  if (u.access === "readonly") {
148
- lines.push(`GRANT CONNECT ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`, `\\connect ${pgIdent(db)}`, `GRANT USAGE ON SCHEMA public TO ${pgIdent(u.username)};`, `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${pgIdent(u.username)};`, `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${pgIdent(u.username)};`, "\\connect postgres");
149
+ lines.push(`GRANT CONNECT ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`, `\\connect ${pgIdent(db)}`, `GRANT USAGE ON SCHEMA public TO ${pgIdent(u.username)};`, `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${pgIdent(u.username)};`, `ALTER DEFAULT PRIVILEGES FOR ROLE ${pgIdent(user)} IN SCHEMA public GRANT SELECT ON TABLES TO ${pgIdent(u.username)};`, "\\connect postgres");
149
150
  } else {
150
151
  lines.push(`GRANT ALL PRIVILEGES ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`);
151
152
  }
@@ -810,7 +811,9 @@ import { startProxies } from '@stacksjs/rpx'
810
811
 
811
812
  const config = ${json} as const
812
813
 
813
- await startProxies(config as any)
814
+ // Verbose is the hard default for ts-cloud-installed gateways (RPX_VERBOSE=false
815
+ // opts out) — without it rpx's TLS/routing diagnostics never reach the journal.
816
+ await startProxies({ verbose: process.env.RPX_VERBOSE !== 'false', ...config } as any)
814
817
  `;
815
818
  }
816
819
  function usesRpxProxy(compute) {
@@ -869,6 +872,12 @@ const config = {
869
872
  https: true,
870
873
  hostsManagement: false,
871
874
  cleanup: { hosts: false, certs: false },
875
+ // Verbose is the hard default for ts-cloud-installed gateways: without it,
876
+ // rpx's TLS/routing diagnostics (tlsx on-demand 'refused issuance',
877
+ // 'issuance failed', 'adopted existing on-disk cert') never reach the systemd
878
+ // journal and a production TLS failure looks like "nothing happens". Silence
879
+ // it by setting RPX_VERBOSE=false on the systemd unit.
880
+ verbose: process.env.RPX_VERBOSE !== 'false',
872
881
  ...(suffixes.size > 0 ? { onDemandTls: { enabled: true, allowedSuffixes: [...suffixes], email, certsDir } } : {}),
873
882
  ...(acmeChallengeWebroot ? { acmeChallengeWebroot } : {}),
874
883
  ...(guard ? { originGuard: { header: guard.header, value: guard.value, hosts: [...guardHosts] } } : {}),
@@ -877,14 +886,14 @@ const config = {
877
886
  await startProxies(config)
878
887
  `;
879
888
  }
880
- function writeFileHeredoc(path, content, delimiter) {
889
+ function writeFileHeredoc(path, content, delimiter, mode = "0644") {
881
890
  return [
882
891
  `__tsc_tmp="$(mktemp "${path}.XXXXXX")"`,
883
892
  `cat > "$__tsc_tmp" <<'${delimiter}'`,
884
893
  content,
885
894
  delimiter,
886
895
  `mv -f "$__tsc_tmp" ${path}`,
887
- `chmod 0644 ${path}`
896
+ `chmod ${mode} ${path}`
888
897
  ];
889
898
  }
890
899
  function certDomainsForConfig(config) {
@@ -986,10 +995,14 @@ function buildRpxProvisionScript(options) {
986
995
  return [
987
996
  "set -euo pipefail",
988
997
  `mkdir -p ${RPX_DIR} ${RPX_SITES_DIR} ${certsDir} ${RPX_INSTALL_DIR}`,
989
- `rm -rf ${RPX_INSTALL_DIR}/node_modules ${RPX_INSTALL_DIR}/bun.lock ${RPX_INSTALL_DIR}/package.json`,
990
- `(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/rpx@${version})`,
998
+ `rm -rf ${RPX_INSTALL_DIR}.next ${RPX_INSTALL_DIR}.prev`,
999
+ `mkdir -p ${RPX_INSTALL_DIR}.next`,
1000
+ `(cd ${RPX_INSTALL_DIR}.next && ${bunBin} add @stacksjs/rpx@${version})`,
1001
+ `mv ${RPX_INSTALL_DIR} ${RPX_INSTALL_DIR}.prev`,
1002
+ `mv ${RPX_INSTALL_DIR}.next ${RPX_INSTALL_DIR}`,
1003
+ `rm -rf ${RPX_INSTALL_DIR}.prev`,
991
1004
  `ln -sfn ${RPX_INSTALL_DIR}/node_modules ${RPX_DIR}/node_modules`,
992
- ...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF"),
1005
+ ...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF", "0600"),
993
1006
  ...writeFileHeredoc(RPX_LAUNCHER_PATH, assembler, "TS_CLOUD_RPX_EOF"),
994
1007
  ...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
995
1008
  "[Unit]",
@@ -1020,13 +1033,23 @@ function buildRpxProvisionScript(options) {
1020
1033
  ...buildCertManagementCommands(options)
1021
1034
  ];
1022
1035
  }
1036
+ function buildRpxFragmentRefreshScript(options) {
1037
+ const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
1038
+ const fragment = JSON.stringify({ slug, ...options.config }, null, 2);
1039
+ return [
1040
+ "set -euo pipefail",
1041
+ `mkdir -p ${RPX_SITES_DIR}`,
1042
+ ...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF", "0600"),
1043
+ `systemctl restart ${RPX_SERVICE_NAME}`
1044
+ ];
1045
+ }
1023
1046
 
1024
1047
  // src/drivers/shared/ufw.ts
1025
1048
  var UFW_BASE_PORTS = [80, 443];
1026
1049
  function buildUfwScript(firewall = {}) {
1027
1050
  if (firewall.enabled === false)
1028
1051
  return [];
1029
- const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => p > 0).sort((a, b) => a - b);
1052
+ const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => Number.isInteger(p) && p >= 1 && p <= 65535).sort((a, b) => a - b);
1030
1053
  const lines = [
1031
1054
  "export DEBIAN_FRONTEND=noninteractive",
1032
1055
  "apt-get install -y ufw",
@@ -1157,25 +1180,23 @@ var BLOCK_BEGIN = "# >>> ts-cloud managed keys >>>";
1157
1180
  var BLOCK_END = "# <<< ts-cloud managed keys <<<";
1158
1181
  var DEFAULT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
1159
1182
  function buildAuthorizedKeysScript(keys = [], options = {}) {
1160
- if (keys.length === 0)
1161
- return [];
1162
1183
  const path = options.path ?? DEFAULT_AUTHORIZED_KEYS;
1163
1184
  const dir = path.replace(/\/[^/]*$/, "");
1164
- const block = [
1165
- BLOCK_BEGIN,
1166
- ...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
1167
- BLOCK_END
1168
- ].join(`
1169
- `);
1170
- return [
1185
+ const lines = [
1171
1186
  `mkdir -p ${dir}`,
1172
1187
  `touch ${path}`,
1173
- `sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}`,
1174
- `cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`,
1175
- block,
1176
- "TS_CLOUD_KEYS_EOF",
1177
- `chmod 600 ${path}`
1188
+ `sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}`
1178
1189
  ];
1190
+ if (keys.length > 0) {
1191
+ const block = [
1192
+ BLOCK_BEGIN,
1193
+ ...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
1194
+ BLOCK_END
1195
+ ].join(`
1196
+ `);
1197
+ lines.push(`cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`, block, "TS_CLOUD_KEYS_EOF", `chmod 600 ${path}`);
1198
+ }
1199
+ return lines;
1179
1200
  }
1180
1201
  function escapeSed(value) {
1181
1202
  return value.replace(/[.*[\]\\/^$]/g, "\\$&");
@@ -1399,11 +1420,12 @@ function buildComputeProvisionScripts(config) {
1399
1420
  ...useNginx ? buildNginxServiceScript() : []
1400
1421
  ] : undefined;
1401
1422
  const extras = [];
1423
+ const appDatabase = resolveAppDatabase(config);
1402
1424
  if (!phpBox && needsPantry)
1403
1425
  extras.push(...pantryBootstrap);
1404
1426
  extras.push(...buildNotifierScript(config.notifications));
1405
1427
  if (compute.managedServices) {
1406
- extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices));
1428
+ extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(appDatabase, compute.managedServices));
1407
1429
  }
1408
1430
  extras.push(...buildUfwScript(compute.firewall ?? (phpBox ? { enabled: true } : { enabled: false })));
1409
1431
  extras.push(...buildAutoUpdatesScript(compute.autoUpdates ?? phpBox));
@@ -1411,7 +1433,7 @@ function buildComputeProvisionScripts(config) {
1411
1433
  extras.push(...buildAuthorizedKeysScript(compute.sshKeys));
1412
1434
  if (compute.backups?.enabled) {
1413
1435
  extras.push(...buildBackupProvisionScript({
1414
- database: config.infrastructure?.appDatabase,
1436
+ database: appDatabase,
1415
1437
  backups: compute.backups
1416
1438
  }));
1417
1439
  }
@@ -1514,7 +1536,6 @@ ln -sf /root/.deno/bin/deno /usr/local/bin/deno
1514
1536
  mkdir -p /var/www /var/ts-cloud/staging /var/ts-cloud/releases
1515
1537
  `;
1516
1538
  if (caddyfile) {
1517
- const escaped = caddyfile.replace(/\$/g, "\\$");
1518
1539
  script += `
1519
1540
  ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
1520
1541
  curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=\${ARCH}" -o /usr/local/bin/caddy
@@ -1552,7 +1573,7 @@ WantedBy=multi-user.target
1552
1573
  CADDY_UNIT_EOF
1553
1574
 
1554
1575
  cat > /etc/caddy/Caddyfile <<'CADDY_CONFIG_EOF'
1555
- ${escaped}
1576
+ ${caddyfile}
1556
1577
  CADDY_CONFIG_EOF
1557
1578
 
1558
1579
  systemctl daemon-reload
@@ -1858,7 +1879,7 @@ class AwsDriver {
1858
1879
  if (!sendResult.CommandId) {
1859
1880
  return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
1860
1881
  }
1861
- return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
1882
+ return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length, ((options.timeoutSeconds || 600) + 30) * 1000);
1862
1883
  }
1863
1884
  if (!options.tags || Object.keys(options.tags).length === 0) {
1864
1885
  return { success: false, instanceCount: 0, perInstance: [], error: "No targets or tags provided for AWS deploy" };
@@ -1881,9 +1902,8 @@ class AwsDriver {
1881
1902
  error: result.error
1882
1903
  };
1883
1904
  }
1884
- async pollSsmCommand(ssm, commandId, expectedCount) {
1905
+ async pollSsmCommand(ssm, commandId, expectedCount, maxWait = 600000) {
1885
1906
  const pollInterval = 3000;
1886
- const maxWait = 600000;
1887
1907
  const startTime = Date.now();
1888
1908
  const terminalStatuses = new Set(["Success", "Failed", "Cancelled", "TimedOut"]);
1889
1909
  let lastInvocations = [];
@@ -2019,9 +2039,21 @@ class HetznerClient {
2019
2039
  }
2020
2040
  return data;
2021
2041
  }
2042
+ async requestAll(path, key) {
2043
+ const items = [];
2044
+ let page = 1;
2045
+ for (;; ) {
2046
+ const sep = path.includes("?") ? "&" : "?";
2047
+ const data = await this.request("GET", `${path}${sep}per_page=50&page=${page}`);
2048
+ items.push(...data[key] ?? []);
2049
+ const next = data.meta?.pagination?.next_page;
2050
+ if (!next)
2051
+ return items;
2052
+ page = next;
2053
+ }
2054
+ }
2022
2055
  async listServers() {
2023
- const data = await this.request("GET", "/servers");
2024
- return data.servers;
2056
+ return this.requestAll("/servers", "servers");
2025
2057
  }
2026
2058
  async getServer(id) {
2027
2059
  const data = await this.request("GET", `/servers/${id}`);
@@ -2044,8 +2076,7 @@ class HetznerClient {
2044
2076
  return { server: data.server, action: data.action };
2045
2077
  }
2046
2078
  async listNetworks() {
2047
- const data = await this.request("GET", "/networks");
2048
- return data.networks;
2079
+ return this.requestAll("/networks", "networks");
2049
2080
  }
2050
2081
  async createNetwork(options) {
2051
2082
  const ipRange = options.ipRange ?? "10.0.0.0/16";
@@ -2061,8 +2092,7 @@ class HetznerClient {
2061
2092
  await this.request("DELETE", `/networks/${id}`);
2062
2093
  }
2063
2094
  async listLoadBalancers() {
2064
- const data = await this.request("GET", "/load_balancers");
2065
- return data.load_balancers;
2095
+ return this.requestAll("/load_balancers", "load_balancers");
2066
2096
  }
2067
2097
  async createLoadBalancer(options) {
2068
2098
  const data = await this.request("POST", "/load_balancers", {
@@ -2097,8 +2127,7 @@ class HetznerClient {
2097
2127
  return data.action;
2098
2128
  }
2099
2129
  async listFirewalls() {
2100
- const data = await this.request("GET", "/firewalls");
2101
- return data.firewalls;
2130
+ return this.requestAll("/firewalls", "firewalls");
2102
2131
  }
2103
2132
  async createFirewall(options) {
2104
2133
  const data = await this.request("POST", "/firewalls", {
@@ -2125,8 +2154,7 @@ class HetznerClient {
2125
2154
  return data.actions;
2126
2155
  }
2127
2156
  async listSshKeys() {
2128
- const data = await this.request("GET", "/ssh_keys");
2129
- return data.ssh_keys;
2157
+ return this.requestAll("/ssh_keys", "ssh_keys");
2130
2158
  }
2131
2159
  async createSshKey(options) {
2132
2160
  const data = await this.request("POST", "/ssh_keys", {
@@ -2223,6 +2251,19 @@ function buildFleetServicesEnv(servicesPrivateIp, database) {
2223
2251
  }
2224
2252
  return env2;
2225
2253
  }
2254
+ function buildFleetServicesBoxProvision(config) {
2255
+ const compute = config.infrastructure?.compute ?? {};
2256
+ const appDatabase = resolveAppDatabase(config);
2257
+ return [
2258
+ ...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
2259
+ ...buildDatabaseSetupScript(appDatabase, compute.managedServices ?? { mysql: true }),
2260
+ ...buildAutoUpdatesScript(true),
2261
+ ...buildMonitoringScript(true),
2262
+ ...buildAuthorizedKeysScript(compute.sshKeys),
2263
+ ...buildNotifierScript(config.notifications),
2264
+ ...compute.backups?.enabled ? [...buildBackupProvisionScript({ database: appDatabase, backups: compute.backups })] : []
2265
+ ];
2266
+ }
2226
2267
 
2227
2268
  // src/drivers/hetzner/firewall-rules.ts
2228
2269
  function buildHetznerFirewallRules(config) {
@@ -2274,7 +2315,7 @@ function matchesTsCloudLabels(labels, slug, environment, role = "app") {
2274
2315
  }
2275
2316
 
2276
2317
  // src/drivers/hetzner/state.ts
2277
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2318
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2278
2319
  import { join as join3 } from "node:path";
2279
2320
  var STATE_DIR = "storage/cloud/state";
2280
2321
  function driverStatePath(stackName) {
@@ -2291,8 +2332,10 @@ async function readDriverState(stackName) {
2291
2332
  async function writeDriverState(stackName, state) {
2292
2333
  const path = driverStatePath(stackName);
2293
2334
  await mkdir(join3(process.cwd(), STATE_DIR), { recursive: true });
2294
- await writeFile(path, `${JSON.stringify(state, null, 2)}
2335
+ const tmp = `${path}.${process.pid}.tmp`;
2336
+ await writeFile(tmp, `${JSON.stringify(state, null, 2)}
2295
2337
  `, "utf8");
2338
+ await rename(tmp, path);
2296
2339
  }
2297
2340
 
2298
2341
  // src/drivers/hetzner/driver.ts
@@ -2300,7 +2343,7 @@ var SSH_MAX_BUFFER = 1024 * 1024 * 256;
2300
2343
  var SSH_ERROR_OUTPUT_LIMIT = 8000;
2301
2344
  function sshErrorOutput(value) {
2302
2345
  const output = Buffer.isBuffer(value) ? value.toString("utf8") : typeof value === "string" ? value : "";
2303
- return output.replace(/(^|\n)([A-Z][A-Z0-9_]*=).*$/gm, "$1$2[redacted]").replace(/encrypted:[A-Za-z0-9+/=]+/g, "encrypted:[redacted]").trim().slice(-SSH_ERROR_OUTPUT_LIMIT);
2346
+ return output.replace(/(^|\n)(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=).*$/gm, "$1$2[redacted]").replace(/encrypted:[A-Za-z0-9+/=]+/g, "encrypted:[redacted]").trim().slice(-SSH_ERROR_OUTPUT_LIMIT);
2304
2347
  }
2305
2348
  function formatSshFailure(error) {
2306
2349
  const childError = error;
@@ -2358,6 +2401,15 @@ class HetznerDriver {
2358
2401
  if (topology.dedicatedServices || topology.appServers > 1) {
2359
2402
  return phpBox ? this.provisionFleet(options, topology) : this.provisionBunFleet(options, topology);
2360
2403
  }
2404
+ const labels = tsCloudLabels(slug, environment, "app");
2405
+ const sites = config.sites || {};
2406
+ const sitePorts = this.collectUpstreamPorts(sites);
2407
+ const firewallName = `${slug}-${environment}-app-fw`;
2408
+ const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
2409
+ allowSsh: compute.allowSsh !== false,
2410
+ sitePorts
2411
+ }));
2412
+ const sshKeyId = await this.ensureSshKey(slug, environment, labels);
2361
2413
  const existing = await readDriverState(stackName);
2362
2414
  if (existing?.serverId) {
2363
2415
  const server2 = await this.tryGetServer(existing.serverId);
@@ -2365,7 +2417,6 @@ class HetznerDriver {
2365
2417
  return this.outputsFromState(existing, server2);
2366
2418
  }
2367
2419
  }
2368
- const labels = tsCloudLabels(slug, environment, "app");
2369
2420
  const alreadyRunning = await this.findExistingServer(slug, environment, serverName);
2370
2421
  if (alreadyRunning && alreadyRunning.status !== "off") {
2371
2422
  const rehydrated = {
@@ -2380,8 +2431,6 @@ class HetznerDriver {
2380
2431
  await writeDriverState(stackName, rehydrated);
2381
2432
  return this.outputsFromState(rehydrated, alreadyRunning);
2382
2433
  }
2383
- const sites = config.sites || {};
2384
- const sitePorts = this.collectUpstreamPorts(sites);
2385
2434
  const rpxProvision = compute.proxy?.engine === "rpx" ? buildRpxProvisionScript({
2386
2435
  proxy: compute.proxy,
2387
2436
  config: buildRpxConfig(sites, { proxy: compute.proxy, slug: config.project.slug }),
@@ -2403,12 +2452,6 @@ class HetznerDriver {
2403
2452
  const userData = wrapCloudInitUserData(bootstrap);
2404
2453
  const serverType = resolveHetznerServerType(compute.size);
2405
2454
  const image = resolveHetznerImage(config);
2406
- const firewallName = `${slug}-${environment}-app-fw`;
2407
- const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
2408
- allowSsh: compute.allowSsh !== false,
2409
- sitePorts
2410
- }));
2411
- const sshKeyId = await this.ensureSshKey(slug, environment, labels);
2412
2455
  const { server, action } = await this.client.createServer({
2413
2456
  name: serverName,
2414
2457
  serverType,
@@ -2474,13 +2517,7 @@ class HetznerDriver {
2474
2517
  { direction: "in", protocol: "tcp", port: "6379", source_ips: [network.ip_range] },
2475
2518
  { direction: "in", protocol: "tcp", port: "7700", source_ips: [network.ip_range] }
2476
2519
  ]);
2477
- const servicesProvision = [
2478
- ...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
2479
- ...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices ?? { mysql: true }),
2480
- ...buildAutoUpdatesScript(true),
2481
- ...buildMonitoringScript(true),
2482
- ...buildAuthorizedKeysScript(compute.sshKeys)
2483
- ];
2520
+ const servicesProvision = buildFleetServicesBoxProvision(config);
2484
2521
  const servicesUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "php", servicesProvision, baked }));
2485
2522
  const all = await this.client.listServers().catch(() => []);
2486
2523
  const newServerIds = [];
@@ -2608,6 +2645,9 @@ class HetznerDriver {
2608
2645
  if (existingState?.lbServerId) {
2609
2646
  const lb = await this.tryGetServer(existingState.lbServerId);
2610
2647
  if (lb && lb.status !== "off") {
2648
+ const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
2649
+ const appTargets = await this.findComputeTargets({ slug, environment, role: "app", stackName });
2650
+ this.refreshLbRoutes(lb, sites, appTargets.map((t) => ({ privateIp: t.privateIp, publicIp: t.publicIp })), rpxProxy, slug);
2611
2651
  return {
2612
2652
  appPublicIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
2613
2653
  loadBalancerIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
@@ -2638,7 +2678,8 @@ class HetznerDriver {
2638
2678
  let servicesServerId;
2639
2679
  let servicesPrivateIp;
2640
2680
  const wantsServicesBox = !!compute.servicesServer || !!compute.managedServices;
2641
- if (topology.dedicatedServices && wantsServicesBox) {
2681
+ const dedicatedServicesHere = topology.dedicatedServices && wantsServicesBox;
2682
+ if (dedicatedServicesHere) {
2642
2683
  const { firewall: svcFw } = await this.ensureFirewall(`${slug}-${environment}-services-fw`, tsCloudLabels(slug, environment, "services"), [
2643
2684
  { direction: "in", protocol: "tcp", port: "22", source_ips: ["0.0.0.0/0", "::/0"] },
2644
2685
  { direction: "in", protocol: "tcp", port: "3306", source_ips: [network.ip_range] },
@@ -2646,13 +2687,7 @@ class HetznerDriver {
2646
2687
  { direction: "in", protocol: "tcp", port: "6379", source_ips: [network.ip_range] },
2647
2688
  { direction: "in", protocol: "tcp", port: "7700", source_ips: [network.ip_range] }
2648
2689
  ]);
2649
- const servicesProvision = [
2650
- ...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
2651
- ...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices ?? { mysql: true }),
2652
- ...buildAutoUpdatesScript(true),
2653
- ...buildMonitoringScript(true),
2654
- ...buildAuthorizedKeysScript(compute.sshKeys)
2655
- ];
2690
+ const servicesProvision = buildFleetServicesBoxProvision(config);
2656
2691
  const servicesUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "bun", servicesProvision, baked }));
2657
2692
  let svcServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "services"));
2658
2693
  if (!svcServer) {
@@ -2674,7 +2709,11 @@ class HetznerDriver {
2674
2709
  servicesServerId = svcServer.id;
2675
2710
  servicesPrivateIp = svcServer.private_net?.[0]?.ip ?? (await this.client.getServer(servicesServerId)).private_net?.[0]?.ip;
2676
2711
  }
2677
- const appProvisionScripts = buildComputeProvisionScripts(config);
2712
+ const appBoxCompute = dedicatedServicesHere ? { ...compute, managedServices: undefined, backups: undefined } : compute;
2713
+ const appProvisionScripts = buildComputeProvisionScripts({
2714
+ ...config,
2715
+ infrastructure: { ...config.infrastructure, compute: appBoxCompute }
2716
+ });
2678
2717
  const appUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({
2679
2718
  runtime: appProvisionScripts.runtime,
2680
2719
  runtimeVersion: appProvisionScripts.runtimeVersion,
@@ -2729,8 +2768,8 @@ class HetznerDriver {
2729
2768
  let lbIp;
2730
2769
  if (topology.loadBalancer) {
2731
2770
  let lbServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "lb"));
2771
+ const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
2732
2772
  if (!lbServer) {
2733
- const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
2734
2773
  const lbRpxProvision = buildRpxProvisionScript({
2735
2774
  proxy: rpxProxy,
2736
2775
  config: buildRpxLbConfig(sites, appBoxes, { proxy: rpxProxy, slug }),
@@ -2763,6 +2802,8 @@ class HetznerDriver {
2763
2802
  await this.waitForCloudInit(ip);
2764
2803
  }
2765
2804
  }
2805
+ } else {
2806
+ this.refreshLbRoutes(lbServer, sites, appBoxes, rpxProxy, slug);
2766
2807
  }
2767
2808
  lbId = lbServer.id;
2768
2809
  lbIp = lbServer.public_net.ipv4?.ip;
@@ -2862,8 +2903,9 @@ class HetznerDriver {
2862
2903
  const stackName = resolveProjectStackName(options.config, options.environment);
2863
2904
  const state = await readDriverState(stackName);
2864
2905
  if (state?.serverId) {
2865
- const server = await this.client.getServer(state.serverId);
2866
- return this.outputsFromState(state, server);
2906
+ const server = await this.tryGetServer(state.serverId);
2907
+ if (server)
2908
+ return this.outputsFromState(state, server);
2867
2909
  }
2868
2910
  if (state?.lbServerId) {
2869
2911
  const lb = await this.tryGetServer(state.lbServerId);
@@ -3008,6 +3050,21 @@ class HetznerDriver {
3008
3050
  return null;
3009
3051
  }
3010
3052
  }
3053
+ refreshLbRoutes(lb, sites, appBoxes, proxy, slug) {
3054
+ const ip = lb.public_net.ipv4?.ip;
3055
+ if (!ip) {
3056
+ console.warn(`[ts-cloud] LB server '${lb.name}' has no public IP — skipping the rpx route refresh; its routes may be stale.`);
3057
+ return;
3058
+ }
3059
+ const routable = appBoxes.filter((box) => box.privateIp || box.publicIp);
3060
+ if (routable.length === 0) {
3061
+ console.warn(`[ts-cloud] No routable app boxes found for LB '${lb.name}' — leaving its current rpx routes untouched.`);
3062
+ return;
3063
+ }
3064
+ const config = buildRpxLbConfig(sites, routable, { proxy, slug });
3065
+ this.sshExec(ip, buildRpxFragmentRefreshScript({ config, slug }).join(`
3066
+ `));
3067
+ }
3011
3068
  async findExistingServer(slug, environment, serverName) {
3012
3069
  const servers = await this.client.listServers();
3013
3070
  const exact = servers.find((server) => matchesTsCloudLabels(server.labels, slug, environment, "app") || server.name === serverName);
@@ -3066,12 +3123,12 @@ class HetznerDriver {
3066
3123
  const start = Date.now();
3067
3124
  while (Date.now() - start < cloudInitTimeoutMs) {
3068
3125
  try {
3069
- const out = this.sshExec(host, "cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || echo status:\\ done");
3070
- if (/status:\s*done/.test(out))
3071
- return;
3126
+ const out = this.sshExec(host, `if command -v cloud-init >/dev/null 2>&1; then cloud-init status --long 2>/dev/null || true; else echo 'status: done'; fi`);
3072
3127
  if (/status:\s*error/.test(out))
3073
3128
  throw new Error(`cloud-init reported an error on ${host}:
3074
3129
  ${out}`);
3130
+ if (/status:\s*(?:done|degraded)/.test(out))
3131
+ return;
3075
3132
  } catch (err) {
3076
3133
  if (err instanceof Error && /cloud-init reported an error/.test(err.message))
3077
3134
  throw err;
@@ -3164,7 +3221,9 @@ class LocalBoxDriver {
3164
3221
  async uploadRelease(options) {
3165
3222
  return { artifactRef: options.localPath };
3166
3223
  }
3167
- async findComputeTargets(_options) {
3224
+ async findComputeTargets(options) {
3225
+ if ((options.role || "app") !== "app")
3226
+ return [];
3168
3227
  return [{ id: "localhost", name: "localhost", publicIp: "127.0.0.1", status: "running" }];
3169
3228
  }
3170
3229
  async runRemoteDeploy(options) {
@@ -3500,7 +3559,7 @@ class AwsBoxProvisioner {
3500
3559
  MinCount: 1,
3501
3560
  MaxCount: 1,
3502
3561
  SecurityGroupIds: [groupId],
3503
- UserData: btoa(buildBoxUserData(spec)),
3562
+ UserData: Buffer.from(buildBoxUserData(spec), "utf8").toString("base64"),
3504
3563
  TagSpecifications: [{ ResourceType: "instance", Tags: tags }]
3505
3564
  });
3506
3565
  const instanceId = result.Instances?.[0]?.InstanceId;
@@ -3717,20 +3776,24 @@ function buildSiteDeployScript(options) {
3717
3776
  const paths = releasePaths(base, releaseId);
3718
3777
  const unitBase = `${slug}-${siteName}`;
3719
3778
  const serviceName = `${unitBase}.service`;
3779
+ const tarball = releaseTarballTmpPath(slug, siteName, releaseId);
3720
3780
  const sharedPaths = [...new Set([".env", ...options.sharedPaths ?? []])];
3721
3781
  const envFile = formatEnvFile(envEntries);
3722
3782
  const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
3723
3783
  const stageRelease = [
3724
3784
  "set -euo pipefail",
3785
+ `trap 'if [ $? -ne 0 ] && [ "$(readlink -f ${paths.current} 2>/dev/null || true)" != "${paths.release}" ]; then rm -rf ${paths.release}; fi' EXIT`,
3725
3786
  ...artifactFetch,
3726
3787
  ...buildEnsureReleaseLayout(paths, sharedPaths),
3727
3788
  `rm -rf ${paths.release}`,
3728
3789
  `mkdir -p ${paths.release}`,
3729
- `tar xzf /tmp/${siteName}-release.tar.gz -C ${paths.release}`,
3790
+ `tar xzf ${tarball} -C ${paths.release}`,
3791
+ `rm -f ${tarball}`,
3730
3792
  `cat > ${paths.shared}/.env <<'TS_CLOUD_ENV_EOF'`,
3731
3793
  envFile,
3732
3794
  "TS_CLOUD_ENV_EOF",
3733
3795
  `chmod 600 ${paths.shared}/.env`,
3796
+ `for TS_CLOUD_ENV in ${paths.shared}/.env ${paths.release}/.env ${paths.release}/.env.*; do [ -f "$TS_CLOUD_ENV" ] && sed -i -E '/^[[:space:]]*(PORT|PORT_BACKEND|PORT_ADMIN|PORT_FRONTEND)[[:space:]]*=/d' "$TS_CLOUD_ENV" 2>/dev/null || true; done`,
3734
3797
  ...buildLinkSharedPaths(paths, sharedPaths),
3735
3798
  ...preStart
3736
3799
  ];
@@ -3767,7 +3830,7 @@ function buildSiteDeployScript(options) {
3767
3830
  ...buildActivateRelease(paths),
3768
3831
  `systemctl enable ${instance} 2>/dev/null || true`,
3769
3832
  `for TS_CLOUD_U in \${TS_CLOUD_OLD_UNITS}; do systemctl stop "$TS_CLOUD_U" 2>/dev/null || true; systemctl disable "$TS_CLOUD_U" 2>/dev/null || true; done`,
3770
- `systemctl list-unit-files --plain --no-legend "${unitBase}@*.service" 2>/dev/null | awk '{print $1}' | grep -v "^${instance}$" | while read -r TS_CLOUD_U; do systemctl disable "$TS_CLOUD_U" 2>/dev/null || true; done`,
3833
+ `systemctl list-unit-files --plain --no-legend "${unitBase}@*.service" 2>/dev/null | awk '{print $1}' | grep -v -e "^${instance}$" -e "^${unitBase}@\\.service$" | while read -r TS_CLOUD_U; do systemctl disable "$TS_CLOUD_U" 2>/dev/null || true; done`,
3771
3834
  `if [ -f /etc/systemd/system/${serviceName} ]; then systemctl disable ${serviceName} 2>/dev/null || true; rm -f /etc/systemd/system/${serviceName}; systemctl daemon-reload; fi`,
3772
3835
  ...buildPruneReleases(paths, keepReleases)
3773
3836
  ];
@@ -3803,27 +3866,34 @@ function buildStaticSiteDeployScript(options) {
3803
3866
  const { siteName, artifactFetch, releaseId, keepReleases = DEFAULT_KEEP_RELEASES, preStartCommands = [] } = options;
3804
3867
  const base = options.appDir ?? `/var/www/${siteName}`;
3805
3868
  const paths = releasePaths(base, releaseId);
3869
+ const tarball = releaseTarballTmpPath(options.slug, siteName, releaseId);
3806
3870
  const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
3807
3871
  return [
3808
3872
  "set -euo pipefail",
3873
+ `trap 'if [ $? -ne 0 ] && [ "$(readlink -f ${paths.current} 2>/dev/null || true)" != "${paths.release}" ]; then rm -rf ${paths.release}; fi' EXIT`,
3809
3874
  ...artifactFetch,
3810
3875
  ...buildEnsureReleaseLayout(paths, []),
3811
3876
  `rm -rf ${paths.release}`,
3812
3877
  `mkdir -p ${paths.release}`,
3813
- `tar xzf /tmp/${siteName}-release.tar.gz -C ${paths.release}`,
3878
+ `tar xzf ${tarball} -C ${paths.release}`,
3879
+ `rm -f ${tarball}`,
3814
3880
  ...preStart,
3815
3881
  ...buildActivateRelease(paths),
3816
3882
  ...buildPruneReleases(paths, keepReleases)
3817
3883
  ];
3818
3884
  }
3819
- function buildAwsArtifactFetch(bucket, key, region, siteName) {
3885
+ function releaseTarballTmpPath(slug, siteName, releaseId) {
3886
+ const parts = [slug, siteName, releaseId].filter(Boolean).join("-");
3887
+ return `/tmp/${parts}-release.tar.gz`;
3888
+ }
3889
+ function buildAwsArtifactFetch(bucket, key, region, destPath) {
3820
3890
  return [
3821
- `aws s3 cp "s3://${bucket}/${key}" /tmp/${siteName}-release.tar.gz --region ${region}`
3891
+ `aws s3 cp "s3://${bucket}/${key}" ${destPath} --region ${region}`
3822
3892
  ];
3823
3893
  }
3824
- function buildLocalArtifactFetch(localPath, siteName) {
3894
+ function buildLocalArtifactFetch(localPath, destPath) {
3825
3895
  return [
3826
- `cp "${localPath}" /tmp/${siteName}-release.tar.gz`
3896
+ `cp "${localPath}" ${destPath}`
3827
3897
  ];
3828
3898
  }
3829
3899
  // src/drivers/shared/compute-deploy.ts
@@ -4087,7 +4157,7 @@ function buildCertbotInstallScript(dns) {
4087
4157
  "mkdir -p /etc/letsencrypt/renewal-hooks/deploy",
4088
4158
  "cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'TS_CLOUD_HOOK_EOF'",
4089
4159
  "#!/bin/sh",
4090
- "systemctl reload nginx",
4160
+ "systemctl reload ts-cloud-nginx 2>/dev/null || systemctl reload nginx 2>/dev/null || true",
4091
4161
  "TS_CLOUD_HOOK_EOF",
4092
4162
  "chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh",
4093
4163
  "systemctl enable certbot.timer 2>/dev/null || true",
@@ -4120,7 +4190,7 @@ function buildCertbotIssueScript(options) {
4120
4190
  args.push(`--${plugin}-propagation-seconds ${dns.propagationSeconds}`);
4121
4191
  args.push("certonly");
4122
4192
  } else {
4123
- args.push("--nginx", options.redirect === false ? "--no-redirect" : "--redirect");
4193
+ args.push("--nginx", `--nginx-ctl ${NGINX_WRAPPER}`, options.redirect === false ? "--no-redirect" : "--redirect");
4124
4194
  }
4125
4195
  if (options.email)
4126
4196
  args.push(`-m ${options.email}`);
@@ -4164,16 +4234,16 @@ function buildGitCheckoutScript(options) {
4164
4234
  ];
4165
4235
  if (repository.strategy === "tag") {
4166
4236
  if (repository.tag) {
4167
- lines.push(`git clone -q --depth 1 --branch ${repository.tag} ${url} ${releaseDir}`);
4237
+ lines.push(`git clone -q --depth 1 --branch ${shellQuote(repository.tag)} ${shellQuote(url)} ${releaseDir}`);
4168
4238
  lines.push(`printf '%s' ${shellQuote(repository.tag)} > ${releaseDir}/.ts-cloud-tag`);
4169
4239
  } else {
4170
4240
  const pattern = repository.tagPattern || DEFAULT_TAG_PATTERN;
4171
- lines.push(`TS_CLOUD_TAG="$(git ls-remote --tags --refs --sort=-v:refname ${url} ${shellQuote(`refs/tags/${pattern}`)} | head -n1 | sed 's#.*refs/tags/##')"`, `test -n "$TS_CLOUD_TAG" || { echo "no tags matching ${pattern} found in ${url}" >&2; exit 1; }`, `git clone -q --depth 1 --branch "$TS_CLOUD_TAG" ${url} ${releaseDir}`, `printf '%s' "$TS_CLOUD_TAG" > ${releaseDir}/.ts-cloud-tag`);
4241
+ lines.push(`TS_CLOUD_TAG="$(git ls-remote --tags --refs --sort=-v:refname ${shellQuote(url)} ${shellQuote(`refs/tags/${pattern}`)} | head -n1 | sed 's#.*refs/tags/##')"`, `test -n "$TS_CLOUD_TAG" || { echo "no tags matching" ${shellQuote(pattern)} "found in" ${shellQuote(url)} >&2; exit 1; }`, `git clone -q --depth 1 --branch "$TS_CLOUD_TAG" ${shellQuote(url)} ${releaseDir}`, `printf '%s' "$TS_CLOUD_TAG" > ${releaseDir}/.ts-cloud-tag`);
4172
4242
  }
4173
4243
  } else if (commit) {
4174
- lines.push(`git -C ${releaseDir} init -q`, `git -C ${releaseDir} remote add origin ${url}`, `git -C ${releaseDir} fetch -q --depth 1 origin ${commit}`, `git -C ${releaseDir} checkout -q FETCH_HEAD`);
4244
+ lines.push(`git -C ${releaseDir} init -q`, `git -C ${releaseDir} remote add origin ${shellQuote(url)}`, `git -C ${releaseDir} fetch -q --depth 1 origin ${shellQuote(commit)}`, `git -C ${releaseDir} checkout -q FETCH_HEAD`);
4175
4245
  } else {
4176
- lines.push(`git clone -q --depth 1 --branch ${branch} ${url} ${releaseDir}`);
4246
+ lines.push(`git clone -q --depth 1 --branch ${shellQuote(branch)} ${shellQuote(url)} ${releaseDir}`);
4177
4247
  }
4178
4248
  lines.push(`git -C ${releaseDir} rev-parse HEAD > ${releaseDir}/.ts-cloud-sha`);
4179
4249
  return lines;
@@ -4589,12 +4659,13 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4589
4659
  const hint = driver.name === "aws" ? `Stack '${stackName}' has no EC2 instances tagged Project=${slug} Environment=${environment} Role=app, and storage/cloud/state/${stackName}.json pins no live instance. For a shared box, record its instanceId there.` : `No Hetzner servers labeled ts-cloud/project=${slug} ts-cloud/environment=${environment} ts-cloud/role=app, and storage/cloud/state/${stackName}.json pins no live server. For a shared box, record its serverId there.`;
4590
4660
  return { success: false, error: hint };
4591
4661
  }
4662
+ const dbEnv = outputs.servicesPrivateIp ? buildFleetServicesEnv(outputs.servicesPrivateIp, resolveAppDatabase(config)) : buildManagedDbEnv(resolveAppDatabase(config));
4663
+ const envWithServices = Object.keys(dbEnv).length > 0 ? { ...dbEnv, ...site.env || {} } : site.env || {};
4592
4664
  if (isPhpSite(site)) {
4593
4665
  const compute2 = config.infrastructure?.compute;
4594
4666
  const phpVersion = site.phpVersion ?? compute2?.php?.default ?? compute2?.php?.versions?.[0];
4595
4667
  const appBase2 = siteInstallBase(slug, siteName);
4596
- const dbEnv = outputs.servicesPrivateIp ? buildFleetServicesEnv(outputs.servicesPrivateIp, config.infrastructure?.appDatabase) : buildManagedDbEnv(config.infrastructure?.appDatabase);
4597
- const siteWithEnv = Object.keys(dbEnv).length > 0 ? { ...site, env: { ...dbEnv, ...site.env || {} } } : site;
4668
+ const siteWithEnv = Object.keys(dbEnv).length > 0 ? { ...site, env: envWithServices } : site;
4598
4669
  const deployScript = buildLaravelDeployScript({
4599
4670
  siteName,
4600
4671
  site: siteWithEnv,
@@ -4661,11 +4732,13 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4661
4732
  remoteKey,
4662
4733
  targets
4663
4734
  });
4664
- const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config.project.region || "us-east-1", siteName) : buildLocalArtifactFetch(uploadResult.artifactRef, siteName);
4735
+ const tarballPath = releaseTarballTmpPath(slug, siteName, sha);
4736
+ const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config.project.region || "us-east-1", tarballPath) : buildLocalArtifactFetch(uploadResult.artifactRef, tarballPath);
4665
4737
  const kind = resolveSiteKind(site);
4666
4738
  const appBase = siteInstallBase(slug, siteName);
4667
4739
  const baseScript = kind === "server-static" ? buildStaticSiteDeployScript({
4668
4740
  siteName,
4741
+ slug,
4669
4742
  appDir: appBase,
4670
4743
  artifactFetch,
4671
4744
  releaseId: sha,
@@ -4677,7 +4750,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4677
4750
  artifactFetch,
4678
4751
  releaseId: sha,
4679
4752
  execStart: resolveExecStart(site.start, runtime),
4680
- envEntries: site.env || {},
4753
+ envEntries: envWithServices,
4681
4754
  port: site.port,
4682
4755
  preStartCommands: site.preStart,
4683
4756
  sharedPaths: site.sharedPaths,
@@ -4707,7 +4780,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4707
4780
  ...buildDeployHistoryHeader(appBase, {
4708
4781
  releaseId: sha,
4709
4782
  commit: sha,
4710
- branch: site.branch ?? "main"
4783
+ branch: site.repository?.branch ?? "main"
4711
4784
  }),
4712
4785
  ...buildSiteOwnerGuard(appBase, slug),
4713
4786
  ...baseScript,
@@ -4740,6 +4813,44 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4740
4813
  perInstance: result.perInstance
4741
4814
  };
4742
4815
  }
4816
+ async function ensureAttachModeDatabase(driver, options, logger) {
4817
+ const { config, environment } = options;
4818
+ if (!config.cloud?.attachTo)
4819
+ return true;
4820
+ const compute = config.infrastructure?.compute;
4821
+ const database = resolveAppDatabase(config);
4822
+ const commands = buildDatabaseSetupScript(database, compute?.managedServices ?? {});
4823
+ if (commands.length === 0)
4824
+ return true;
4825
+ const slug = config.project.slug;
4826
+ const stackName = resolveProjectStackName(config, environment);
4827
+ const targets = await driver.findComputeTargets({
4828
+ slug,
4829
+ environment,
4830
+ role: "app",
4831
+ stackName
4832
+ });
4833
+ if (targets.length === 0) {
4834
+ return true;
4835
+ }
4836
+ logger.step(`Ensuring database '${database?.name}' + role exist on the shared box...`);
4837
+ const result = await driver.runRemoteDeploy({
4838
+ targets,
4839
+ commands,
4840
+ comment: `ts-cloud ensure database ${slug}/${database?.name}`,
4841
+ tags: {
4842
+ Project: slug,
4843
+ Environment: environment,
4844
+ Role: "app"
4845
+ }
4846
+ });
4847
+ if (!result.success) {
4848
+ logger.error(`Ensuring database '${database?.name}' failed: ${result.error || "unknown error"}`);
4849
+ return false;
4850
+ }
4851
+ logger.success(`Database '${database?.name}' is ready on ${result.instanceCount} target(s)`);
4852
+ return true;
4853
+ }
4743
4854
  async function deployAllComputeSites(options) {
4744
4855
  const { config, environment, driver, sha, runtime, tarballForSite, logger = noopLogger2 } = options;
4745
4856
  const slug = config.project.slug;
@@ -4790,6 +4901,8 @@ async function deployAllComputeSites(options) {
4790
4901
  });
4791
4902
  if (deployable.length === 0)
4792
4903
  return true;
4904
+ if (!await ensureAttachModeDatabase(driver, options, logger))
4905
+ return false;
4793
4906
  const tarballFor = (siteName) => dashboardTarballBySite.get(siteName) ?? tarballForSite(siteName);
4794
4907
  for (const [siteName, site] of deployable) {
4795
4908
  logger.step(`Deploying site: ${siteName}`);
@@ -4832,29 +4945,68 @@ async function reloadRpxGateway(options) {
4832
4945
  if (proxy?.engine !== "rpx")
4833
4946
  return true;
4834
4947
  const sites = routeSource.sites || {};
4835
- const rpxConfig = buildRpxConfig(sites, { proxy, slug: config.project.slug });
4948
+ const slug = config.project.slug;
4949
+ const stackName = resolveProjectStackName(config, environment);
4950
+ const lbTargets = await driver.findComputeTargets({
4951
+ slug,
4952
+ environment,
4953
+ role: "lb",
4954
+ stackName
4955
+ });
4956
+ if (lbTargets.length > 0) {
4957
+ const appTargets = await driver.findComputeTargets({
4958
+ slug,
4959
+ environment,
4960
+ role: "app",
4961
+ stackName
4962
+ });
4963
+ const appBoxes = appTargets.map((t) => ({ privateIp: t.privateIp, publicIp: t.publicIp }));
4964
+ const lbConfig = buildRpxLbConfig(sites, appBoxes, { proxy, slug });
4965
+ if (lbConfig.proxies.length === 0) {
4966
+ logger.warn("rpx gateway: no server sites with a domain to route — skipping gateway reload.");
4967
+ return true;
4968
+ }
4969
+ logger.step(`Reloading the load balancer's rpx gateway with ${lbConfig.proxies.length} route(s)...`);
4970
+ const result2 = await driver.runRemoteDeploy({
4971
+ targets: lbTargets,
4972
+ commands: buildRpxFragmentRefreshScript({ config: lbConfig, slug }),
4973
+ comment: `ts-cloud rpx gateway reload ${slug}`,
4974
+ tags: {
4975
+ Project: slug,
4976
+ Environment: environment,
4977
+ Role: "lb"
4978
+ }
4979
+ });
4980
+ if (!result2.success) {
4981
+ logger.error(`rpx gateway reload failed: ${result2.error || "unknown error"}`);
4982
+ return false;
4983
+ }
4984
+ logger.success(`rpx gateway reloaded on ${result2.instanceCount} load balancer(s)`);
4985
+ return true;
4986
+ }
4987
+ const rpxConfig = buildRpxConfig(sites, { proxy, slug });
4836
4988
  if (rpxConfig.proxies.length === 0) {
4837
4989
  logger.warn("rpx gateway: no server sites with a domain to route — skipping gateway reload.");
4838
4990
  return true;
4839
4991
  }
4840
4992
  const targets = await driver.findComputeTargets({
4841
- slug: config.project.slug,
4993
+ slug,
4842
4994
  environment,
4843
4995
  role: "app",
4844
- stackName: resolveProjectStackName(config, environment)
4996
+ stackName
4845
4997
  });
4846
4998
  if (targets.length === 0) {
4847
4999
  logger.warn("rpx gateway: no compute targets found — skipping gateway reload.");
4848
5000
  return true;
4849
5001
  }
4850
5002
  logger.step(`Reloading rpx gateway with ${rpxConfig.proxies.length} route(s)...`);
4851
- const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug: config.project.slug });
5003
+ const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug });
4852
5004
  const result = await driver.runRemoteDeploy({
4853
5005
  targets,
4854
5006
  commands: script,
4855
- comment: `ts-cloud rpx gateway reload ${config.project.slug}`,
5007
+ comment: `ts-cloud rpx gateway reload ${slug}`,
4856
5008
  tags: {
4857
- Project: config.project.slug,
5009
+ Project: slug,
4858
5010
  Environment: environment,
4859
5011
  Role: "app"
4860
5012
  }
@@ -4946,4 +5098,4 @@ function buildCloudFrontOriginConfig(options) {
4946
5098
  CustomErrorResponses: { Quantity: 0 }
4947
5099
  };
4948
5100
  }
4949
- export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, DEFAULT_RPX_CERTS_DIR, normalizeRoutePath, deriveRouteId, buildRpxConfig, buildRpxLbConfig, renderRpxLauncher, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, buildRpxProvisionScript, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, resolveHetznerLocation, HetznerClient, resolveHetznerApiToken2 as resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, UBUNTU_2404_AMI_PARAM, buildBoxUserData, HetznerBoxProvisioner, AwsBoxProvisioner, createBoxProvisioner, releasePaths, buildRollbackScript, resolveExecStart, buildSiteDeployScript, buildStaticSiteDeployScript, buildAwsArtifactFetch, buildLocalArtifactFetch, MANAGEMENT_DASHBOARD_SITE, DASHBOARD_CREDENTIALS_FILE, resolveDashboardAuth, resolveUiSource, ensureManagementDashboard, buildManagementDashboardArtifact, resolveSiteFramework, deploySiteRelease, deployAllComputeSites, reloadRpxGateway, MANAGED_CACHE_POLICY_OPTIMIZED, MANAGED_CACHE_POLICY_DISABLED, MANAGED_ORIGIN_REQUEST_POLICY_ALL_VIEWER, buildCloudFrontOriginConfig };
5101
+ 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, buildRpxFragmentRefreshScript, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, resolveHetznerLocation, HetznerClient, resolveHetznerApiToken2 as resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, UBUNTU_2404_AMI_PARAM, buildBoxUserData, HetznerBoxProvisioner, AwsBoxProvisioner, createBoxProvisioner, releasePaths, buildRollbackScript, resolveExecStart, buildSiteDeployScript, buildStaticSiteDeployScript, releaseTarballTmpPath, 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 };