@stacksjs/ts-cloud 0.7.30 → 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-xdd6qvre.js → chunk-0c69nbaa.js} +252 -101
  3. package/dist/{chunk-rr3j38qr.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,16 +3776,19 @@ 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",
@@ -3768,7 +3830,7 @@ function buildSiteDeployScript(options) {
3768
3830
  ...buildActivateRelease(paths),
3769
3831
  `systemctl enable ${instance} 2>/dev/null || true`,
3770
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`,
3771
- `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`,
3772
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`,
3773
3835
  ...buildPruneReleases(paths, keepReleases)
3774
3836
  ];
@@ -3804,27 +3866,34 @@ function buildStaticSiteDeployScript(options) {
3804
3866
  const { siteName, artifactFetch, releaseId, keepReleases = DEFAULT_KEEP_RELEASES, preStartCommands = [] } = options;
3805
3867
  const base = options.appDir ?? `/var/www/${siteName}`;
3806
3868
  const paths = releasePaths(base, releaseId);
3869
+ const tarball = releaseTarballTmpPath(options.slug, siteName, releaseId);
3807
3870
  const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
3808
3871
  return [
3809
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`,
3810
3874
  ...artifactFetch,
3811
3875
  ...buildEnsureReleaseLayout(paths, []),
3812
3876
  `rm -rf ${paths.release}`,
3813
3877
  `mkdir -p ${paths.release}`,
3814
- `tar xzf /tmp/${siteName}-release.tar.gz -C ${paths.release}`,
3878
+ `tar xzf ${tarball} -C ${paths.release}`,
3879
+ `rm -f ${tarball}`,
3815
3880
  ...preStart,
3816
3881
  ...buildActivateRelease(paths),
3817
3882
  ...buildPruneReleases(paths, keepReleases)
3818
3883
  ];
3819
3884
  }
3820
- 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) {
3821
3890
  return [
3822
- `aws s3 cp "s3://${bucket}/${key}" /tmp/${siteName}-release.tar.gz --region ${region}`
3891
+ `aws s3 cp "s3://${bucket}/${key}" ${destPath} --region ${region}`
3823
3892
  ];
3824
3893
  }
3825
- function buildLocalArtifactFetch(localPath, siteName) {
3894
+ function buildLocalArtifactFetch(localPath, destPath) {
3826
3895
  return [
3827
- `cp "${localPath}" /tmp/${siteName}-release.tar.gz`
3896
+ `cp "${localPath}" ${destPath}`
3828
3897
  ];
3829
3898
  }
3830
3899
  // src/drivers/shared/compute-deploy.ts
@@ -4088,7 +4157,7 @@ function buildCertbotInstallScript(dns) {
4088
4157
  "mkdir -p /etc/letsencrypt/renewal-hooks/deploy",
4089
4158
  "cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'TS_CLOUD_HOOK_EOF'",
4090
4159
  "#!/bin/sh",
4091
- "systemctl reload nginx",
4160
+ "systemctl reload ts-cloud-nginx 2>/dev/null || systemctl reload nginx 2>/dev/null || true",
4092
4161
  "TS_CLOUD_HOOK_EOF",
4093
4162
  "chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh",
4094
4163
  "systemctl enable certbot.timer 2>/dev/null || true",
@@ -4121,7 +4190,7 @@ function buildCertbotIssueScript(options) {
4121
4190
  args.push(`--${plugin}-propagation-seconds ${dns.propagationSeconds}`);
4122
4191
  args.push("certonly");
4123
4192
  } else {
4124
- args.push("--nginx", options.redirect === false ? "--no-redirect" : "--redirect");
4193
+ args.push("--nginx", `--nginx-ctl ${NGINX_WRAPPER}`, options.redirect === false ? "--no-redirect" : "--redirect");
4125
4194
  }
4126
4195
  if (options.email)
4127
4196
  args.push(`-m ${options.email}`);
@@ -4165,16 +4234,16 @@ function buildGitCheckoutScript(options) {
4165
4234
  ];
4166
4235
  if (repository.strategy === "tag") {
4167
4236
  if (repository.tag) {
4168
- 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}`);
4169
4238
  lines.push(`printf '%s' ${shellQuote(repository.tag)} > ${releaseDir}/.ts-cloud-tag`);
4170
4239
  } else {
4171
4240
  const pattern = repository.tagPattern || DEFAULT_TAG_PATTERN;
4172
- 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`);
4173
4242
  }
4174
4243
  } else if (commit) {
4175
- 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`);
4176
4245
  } else {
4177
- 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}`);
4178
4247
  }
4179
4248
  lines.push(`git -C ${releaseDir} rev-parse HEAD > ${releaseDir}/.ts-cloud-sha`);
4180
4249
  return lines;
@@ -4590,12 +4659,13 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4590
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.`;
4591
4660
  return { success: false, error: hint };
4592
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 || {};
4593
4664
  if (isPhpSite(site)) {
4594
4665
  const compute2 = config.infrastructure?.compute;
4595
4666
  const phpVersion = site.phpVersion ?? compute2?.php?.default ?? compute2?.php?.versions?.[0];
4596
4667
  const appBase2 = siteInstallBase(slug, siteName);
4597
- const dbEnv = outputs.servicesPrivateIp ? buildFleetServicesEnv(outputs.servicesPrivateIp, config.infrastructure?.appDatabase) : buildManagedDbEnv(config.infrastructure?.appDatabase);
4598
- 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;
4599
4669
  const deployScript = buildLaravelDeployScript({
4600
4670
  siteName,
4601
4671
  site: siteWithEnv,
@@ -4662,11 +4732,13 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4662
4732
  remoteKey,
4663
4733
  targets
4664
4734
  });
4665
- 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);
4666
4737
  const kind = resolveSiteKind(site);
4667
4738
  const appBase = siteInstallBase(slug, siteName);
4668
4739
  const baseScript = kind === "server-static" ? buildStaticSiteDeployScript({
4669
4740
  siteName,
4741
+ slug,
4670
4742
  appDir: appBase,
4671
4743
  artifactFetch,
4672
4744
  releaseId: sha,
@@ -4678,7 +4750,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4678
4750
  artifactFetch,
4679
4751
  releaseId: sha,
4680
4752
  execStart: resolveExecStart(site.start, runtime),
4681
- envEntries: site.env || {},
4753
+ envEntries: envWithServices,
4682
4754
  port: site.port,
4683
4755
  preStartCommands: site.preStart,
4684
4756
  sharedPaths: site.sharedPaths,
@@ -4708,7 +4780,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4708
4780
  ...buildDeployHistoryHeader(appBase, {
4709
4781
  releaseId: sha,
4710
4782
  commit: sha,
4711
- branch: site.branch ?? "main"
4783
+ branch: site.repository?.branch ?? "main"
4712
4784
  }),
4713
4785
  ...buildSiteOwnerGuard(appBase, slug),
4714
4786
  ...baseScript,
@@ -4741,6 +4813,44 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4741
4813
  perInstance: result.perInstance
4742
4814
  };
4743
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
+ }
4744
4854
  async function deployAllComputeSites(options) {
4745
4855
  const { config, environment, driver, sha, runtime, tarballForSite, logger = noopLogger2 } = options;
4746
4856
  const slug = config.project.slug;
@@ -4791,6 +4901,8 @@ async function deployAllComputeSites(options) {
4791
4901
  });
4792
4902
  if (deployable.length === 0)
4793
4903
  return true;
4904
+ if (!await ensureAttachModeDatabase(driver, options, logger))
4905
+ return false;
4794
4906
  const tarballFor = (siteName) => dashboardTarballBySite.get(siteName) ?? tarballForSite(siteName);
4795
4907
  for (const [siteName, site] of deployable) {
4796
4908
  logger.step(`Deploying site: ${siteName}`);
@@ -4833,29 +4945,68 @@ async function reloadRpxGateway(options) {
4833
4945
  if (proxy?.engine !== "rpx")
4834
4946
  return true;
4835
4947
  const sites = routeSource.sites || {};
4836
- 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 });
4837
4988
  if (rpxConfig.proxies.length === 0) {
4838
4989
  logger.warn("rpx gateway: no server sites with a domain to route — skipping gateway reload.");
4839
4990
  return true;
4840
4991
  }
4841
4992
  const targets = await driver.findComputeTargets({
4842
- slug: config.project.slug,
4993
+ slug,
4843
4994
  environment,
4844
4995
  role: "app",
4845
- stackName: resolveProjectStackName(config, environment)
4996
+ stackName
4846
4997
  });
4847
4998
  if (targets.length === 0) {
4848
4999
  logger.warn("rpx gateway: no compute targets found — skipping gateway reload.");
4849
5000
  return true;
4850
5001
  }
4851
5002
  logger.step(`Reloading rpx gateway with ${rpxConfig.proxies.length} route(s)...`);
4852
- const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug: config.project.slug });
5003
+ const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug });
4853
5004
  const result = await driver.runRemoteDeploy({
4854
5005
  targets,
4855
5006
  commands: script,
4856
- comment: `ts-cloud rpx gateway reload ${config.project.slug}`,
5007
+ comment: `ts-cloud rpx gateway reload ${slug}`,
4857
5008
  tags: {
4858
- Project: config.project.slug,
5009
+ Project: slug,
4859
5010
  Environment: environment,
4860
5011
  Role: "app"
4861
5012
  }
@@ -4947,4 +5098,4 @@ function buildCloudFrontOriginConfig(options) {
4947
5098
  CustomErrorResponses: { Quantity: 0 }
4948
5099
  };
4949
5100
  }
4950
- 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 };