@stacksjs/ts-cloud 0.7.30 → 0.7.32

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 (46) hide show
  1. package/dist/bin/cli.js +781 -774
  2. package/dist/{chunk-xdd6qvre.js → chunk-dah449r1.js} +268 -105
  3. package/dist/{chunk-4ew46pj6.js → chunk-ebx9gjnn.js} +2 -2
  4. package/dist/{chunk-rr3j38qr.js → chunk-jgenfdz6.js} +28 -26
  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/dashboard-database.d.ts +8 -6
  8. package/dist/deploy/index.js +4 -4
  9. package/dist/drivers/hetzner/client.d.ts +8 -0
  10. package/dist/drivers/hetzner/driver.d.ts +16 -0
  11. package/dist/drivers/index.d.ts +3 -3
  12. package/dist/drivers/index.js +6 -2
  13. package/dist/drivers/local-box/driver.d.ts +1 -1
  14. package/dist/drivers/shared/backups.d.ts +3 -1
  15. package/dist/drivers/shared/compute-deploy.d.ts +9 -3
  16. package/dist/drivers/shared/db-provision.d.ts +24 -2
  17. package/dist/drivers/shared/deploy-script.d.ts +12 -2
  18. package/dist/drivers/shared/fleet.d.ts +12 -1
  19. package/dist/drivers/shared/rpx-gateway.d.ts +26 -0
  20. package/dist/drivers/shared/ssh-keys.d.ts +3 -2
  21. package/dist/index.js +6 -4
  22. package/dist/ui/index.html +3 -3
  23. package/dist/ui/server/actions.html +3 -3
  24. package/dist/ui/server/backups.html +3 -3
  25. package/dist/ui/server/database.html +3 -3
  26. package/dist/ui/server/deployments.html +3 -3
  27. package/dist/ui/server/firewall.html +3 -3
  28. package/dist/ui/server/logs.html +3 -3
  29. package/dist/ui/server/services.html +3 -3
  30. package/dist/ui/server/sites.html +3 -3
  31. package/dist/ui/server/ssh-keys.html +3 -3
  32. package/dist/ui/server/team.html +3 -3
  33. package/dist/ui/server/terminal.html +3 -3
  34. package/dist/ui/server/workers.html +3 -3
  35. package/dist/ui/serverless/alarms.html +3 -3
  36. package/dist/ui/serverless/assets.html +3 -3
  37. package/dist/ui/serverless/data.html +3 -3
  38. package/dist/ui/serverless/deployments.html +3 -3
  39. package/dist/ui/serverless/functions.html +3 -3
  40. package/dist/ui/serverless/logs.html +3 -3
  41. package/dist/ui/serverless/queues.html +3 -3
  42. package/dist/ui/serverless/scheduler.html +3 -3
  43. package/dist/ui/serverless/secrets.html +3 -3
  44. package/dist/ui/serverless/traces.html +3 -3
  45. package/dist/ui/serverless.html +3 -3
  46. 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
@@ -77,6 +78,17 @@ function buildPantryServiceScript(services) {
77
78
  }
78
79
 
79
80
  // src/drivers/shared/db-provision.ts
81
+ function isLocalDatabase(database) {
82
+ return !database?.host || database.host === "127.0.0.1" || database.host === "localhost";
83
+ }
84
+ function pgAdminCommand(database, tool = "psql") {
85
+ const port = database?.port ?? 5432;
86
+ if (isLocalDatabase(database))
87
+ return `${tool} -p ${port} -U postgres`;
88
+ const user = database?.username || "postgres";
89
+ const env = database?.password ? `PGPASSWORD='${database.password.replace(/'/g, `'\\''`)}' ` : "";
90
+ return `${env}${tool} -h ${database.host} -p ${port} -U ${user} -w`;
91
+ }
80
92
  function enabled(spec) {
81
93
  return spec === true || typeof spec === "object" && spec != null;
82
94
  }
@@ -120,7 +132,7 @@ function buildServicesProvisionScript(services = {}, _options = {}) {
120
132
  function buildDatabaseSetupScript(database, services = {}) {
121
133
  if (!database?.name)
122
134
  return [];
123
- if (database.host && database.host !== "127.0.0.1" && database.host !== "localhost")
135
+ if (!isLocalDatabase(database))
124
136
  return [];
125
137
  const name = database.name;
126
138
  const user = database.username || name;
@@ -145,7 +157,7 @@ function buildDatabaseSetupScript(database, services = {}) {
145
157
  const lines = [];
146
158
  for (const db of dbs) {
147
159
  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");
160
+ 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
161
  } else {
150
162
  lines.push(`GRANT ALL PRIVILEGES ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`);
151
163
  }
@@ -153,10 +165,11 @@ function buildDatabaseSetupScript(database, services = {}) {
153
165
  return lines;
154
166
  };
155
167
  const extraUsers2 = database.users || [];
168
+ const pgPort = database.port ?? 5432;
156
169
  return [
157
170
  pantryEnvActivation(),
158
- "for i in $(seq 1 30); do pg_isready -h 127.0.0.1 -p 5432 -q && break; sleep 2; done",
159
- "psql -h 127.0.0.1 -p 5432 -U postgres <<'TS_CLOUD_PG_EOF'",
171
+ `for i in $(seq 1 30); do pg_isready -p ${pgPort} -q && break; sleep 2; done`,
172
+ `${pgAdminCommand(database)} <<'TS_CLOUD_PG_EOF'`,
160
173
  ...pgEnsureRole(user, pass),
161
174
  `SELECT 'CREATE DATABASE ${pgIdent(name)} OWNER ${pgIdent(user)}' ` + `WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = ${pgLit(name)})\\gexec`,
162
175
  ...extraUsers2.flatMap((u) => [...pgEnsureRole(u.username, u.password), ...pgGrant(u)]),
@@ -810,7 +823,9 @@ import { startProxies } from '@stacksjs/rpx'
810
823
 
811
824
  const config = ${json} as const
812
825
 
813
- await startProxies(config as any)
826
+ // Verbose is the hard default for ts-cloud-installed gateways (RPX_VERBOSE=false
827
+ // opts out) — without it rpx's TLS/routing diagnostics never reach the journal.
828
+ await startProxies({ verbose: process.env.RPX_VERBOSE !== 'false', ...config } as any)
814
829
  `;
815
830
  }
816
831
  function usesRpxProxy(compute) {
@@ -869,6 +884,12 @@ const config = {
869
884
  https: true,
870
885
  hostsManagement: false,
871
886
  cleanup: { hosts: false, certs: false },
887
+ // Verbose is the hard default for ts-cloud-installed gateways: without it,
888
+ // rpx's TLS/routing diagnostics (tlsx on-demand 'refused issuance',
889
+ // 'issuance failed', 'adopted existing on-disk cert') never reach the systemd
890
+ // journal and a production TLS failure looks like "nothing happens". Silence
891
+ // it by setting RPX_VERBOSE=false on the systemd unit.
892
+ verbose: process.env.RPX_VERBOSE !== 'false',
872
893
  ...(suffixes.size > 0 ? { onDemandTls: { enabled: true, allowedSuffixes: [...suffixes], email, certsDir } } : {}),
873
894
  ...(acmeChallengeWebroot ? { acmeChallengeWebroot } : {}),
874
895
  ...(guard ? { originGuard: { header: guard.header, value: guard.value, hosts: [...guardHosts] } } : {}),
@@ -877,14 +898,14 @@ const config = {
877
898
  await startProxies(config)
878
899
  `;
879
900
  }
880
- function writeFileHeredoc(path, content, delimiter) {
901
+ function writeFileHeredoc(path, content, delimiter, mode = "0644") {
881
902
  return [
882
903
  `__tsc_tmp="$(mktemp "${path}.XXXXXX")"`,
883
904
  `cat > "$__tsc_tmp" <<'${delimiter}'`,
884
905
  content,
885
906
  delimiter,
886
907
  `mv -f "$__tsc_tmp" ${path}`,
887
- `chmod 0644 ${path}`
908
+ `chmod ${mode} ${path}`
888
909
  ];
889
910
  }
890
911
  function certDomainsForConfig(config) {
@@ -986,10 +1007,14 @@ function buildRpxProvisionScript(options) {
986
1007
  return [
987
1008
  "set -euo pipefail",
988
1009
  `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})`,
1010
+ `rm -rf ${RPX_INSTALL_DIR}.next ${RPX_INSTALL_DIR}.prev`,
1011
+ `mkdir -p ${RPX_INSTALL_DIR}.next`,
1012
+ `(cd ${RPX_INSTALL_DIR}.next && ${bunBin} add @stacksjs/rpx@${version})`,
1013
+ `mv ${RPX_INSTALL_DIR} ${RPX_INSTALL_DIR}.prev`,
1014
+ `mv ${RPX_INSTALL_DIR}.next ${RPX_INSTALL_DIR}`,
1015
+ `rm -rf ${RPX_INSTALL_DIR}.prev`,
991
1016
  `ln -sfn ${RPX_INSTALL_DIR}/node_modules ${RPX_DIR}/node_modules`,
992
- ...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF"),
1017
+ ...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF", "0600"),
993
1018
  ...writeFileHeredoc(RPX_LAUNCHER_PATH, assembler, "TS_CLOUD_RPX_EOF"),
994
1019
  ...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
995
1020
  "[Unit]",
@@ -1020,13 +1045,23 @@ function buildRpxProvisionScript(options) {
1020
1045
  ...buildCertManagementCommands(options)
1021
1046
  ];
1022
1047
  }
1048
+ function buildRpxFragmentRefreshScript(options) {
1049
+ const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
1050
+ const fragment = JSON.stringify({ slug, ...options.config }, null, 2);
1051
+ return [
1052
+ "set -euo pipefail",
1053
+ `mkdir -p ${RPX_SITES_DIR}`,
1054
+ ...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF", "0600"),
1055
+ `systemctl restart ${RPX_SERVICE_NAME}`
1056
+ ];
1057
+ }
1023
1058
 
1024
1059
  // src/drivers/shared/ufw.ts
1025
1060
  var UFW_BASE_PORTS = [80, 443];
1026
1061
  function buildUfwScript(firewall = {}) {
1027
1062
  if (firewall.enabled === false)
1028
1063
  return [];
1029
- const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => p > 0).sort((a, b) => a - b);
1064
+ const ports = [...new Set([...UFW_BASE_PORTS, ...firewall.allowedPorts || []])].filter((p) => Number.isInteger(p) && p >= 1 && p <= 65535).sort((a, b) => a - b);
1030
1065
  const lines = [
1031
1066
  "export DEBIAN_FRONTEND=noninteractive",
1032
1067
  "apt-get install -y ufw",
@@ -1157,25 +1192,23 @@ var BLOCK_BEGIN = "# >>> ts-cloud managed keys >>>";
1157
1192
  var BLOCK_END = "# <<< ts-cloud managed keys <<<";
1158
1193
  var DEFAULT_AUTHORIZED_KEYS = "/root/.ssh/authorized_keys";
1159
1194
  function buildAuthorizedKeysScript(keys = [], options = {}) {
1160
- if (keys.length === 0)
1161
- return [];
1162
1195
  const path = options.path ?? DEFAULT_AUTHORIZED_KEYS;
1163
1196
  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 [
1197
+ const lines = [
1171
1198
  `mkdir -p ${dir}`,
1172
1199
  `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}`
1200
+ `sed -i '/^${escapeSed(BLOCK_BEGIN)}$/,/^${escapeSed(BLOCK_END)}$/d' ${path}`
1178
1201
  ];
1202
+ if (keys.length > 0) {
1203
+ const block = [
1204
+ BLOCK_BEGIN,
1205
+ ...keys.map((k) => `${k.publicKey.trim()} ${k.name}`),
1206
+ BLOCK_END
1207
+ ].join(`
1208
+ `);
1209
+ lines.push(`cat >> ${path} <<'TS_CLOUD_KEYS_EOF'`, block, "TS_CLOUD_KEYS_EOF", `chmod 600 ${path}`);
1210
+ }
1211
+ return lines;
1179
1212
  }
1180
1213
  function escapeSed(value) {
1181
1214
  return value.replace(/[.*[\]\\/^$]/g, "\\$&");
@@ -1367,7 +1400,7 @@ function buildBackupRestoreScript(database, options = {}) {
1367
1400
  const name = database.name;
1368
1401
  const isPg = database.engine === "postgres";
1369
1402
  const locate = options.from ? `TS_CLOUD_DUMP="${options.from}"` : `TS_CLOUD_DUMP="$(find ${BACKUP_OUTPUT_DIR} -type f -name '*${name}*.sql' -o -type f -name '*${name}*.sql.gz' 2>/dev/null | xargs -r ls -1t 2>/dev/null | head -1)"`;
1370
- const client = isPg ? `psql -h 127.0.0.1 -p ${database.port ?? 5432} -U postgres -d "${name}"` : `mysql --socket=${engineSocket(database.engine)} -u root "${name}"`;
1403
+ const client = isPg ? `${pgAdminCommand(database)} -d "${name}"` : `mysql --socket=${engineSocket(database.engine)} -u root "${name}"`;
1371
1404
  return [
1372
1405
  "set -uo pipefail",
1373
1406
  'eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true',
@@ -1399,11 +1432,12 @@ function buildComputeProvisionScripts(config) {
1399
1432
  ...useNginx ? buildNginxServiceScript() : []
1400
1433
  ] : undefined;
1401
1434
  const extras = [];
1435
+ const appDatabase = resolveAppDatabase(config);
1402
1436
  if (!phpBox && needsPantry)
1403
1437
  extras.push(...pantryBootstrap);
1404
1438
  extras.push(...buildNotifierScript(config.notifications));
1405
1439
  if (compute.managedServices) {
1406
- extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(config.infrastructure?.appDatabase, compute.managedServices));
1440
+ extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(appDatabase, compute.managedServices));
1407
1441
  }
1408
1442
  extras.push(...buildUfwScript(compute.firewall ?? (phpBox ? { enabled: true } : { enabled: false })));
1409
1443
  extras.push(...buildAutoUpdatesScript(compute.autoUpdates ?? phpBox));
@@ -1411,7 +1445,7 @@ function buildComputeProvisionScripts(config) {
1411
1445
  extras.push(...buildAuthorizedKeysScript(compute.sshKeys));
1412
1446
  if (compute.backups?.enabled) {
1413
1447
  extras.push(...buildBackupProvisionScript({
1414
- database: config.infrastructure?.appDatabase,
1448
+ database: appDatabase,
1415
1449
  backups: compute.backups
1416
1450
  }));
1417
1451
  }
@@ -1514,7 +1548,6 @@ ln -sf /root/.deno/bin/deno /usr/local/bin/deno
1514
1548
  mkdir -p /var/www /var/ts-cloud/staging /var/ts-cloud/releases
1515
1549
  `;
1516
1550
  if (caddyfile) {
1517
- const escaped = caddyfile.replace(/\$/g, "\\$");
1518
1551
  script += `
1519
1552
  ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
1520
1553
  curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=\${ARCH}" -o /usr/local/bin/caddy
@@ -1552,7 +1585,7 @@ WantedBy=multi-user.target
1552
1585
  CADDY_UNIT_EOF
1553
1586
 
1554
1587
  cat > /etc/caddy/Caddyfile <<'CADDY_CONFIG_EOF'
1555
- ${escaped}
1588
+ ${caddyfile}
1556
1589
  CADDY_CONFIG_EOF
1557
1590
 
1558
1591
  systemctl daemon-reload
@@ -1858,7 +1891,7 @@ class AwsDriver {
1858
1891
  if (!sendResult.CommandId) {
1859
1892
  return { success: false, instanceCount: 0, perInstance: [], error: "Failed to send SSM command" };
1860
1893
  }
1861
- return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length);
1894
+ return this.pollSsmCommand(ssm, sendResult.CommandId, options.targets.length, ((options.timeoutSeconds || 600) + 30) * 1000);
1862
1895
  }
1863
1896
  if (!options.tags || Object.keys(options.tags).length === 0) {
1864
1897
  return { success: false, instanceCount: 0, perInstance: [], error: "No targets or tags provided for AWS deploy" };
@@ -1881,9 +1914,8 @@ class AwsDriver {
1881
1914
  error: result.error
1882
1915
  };
1883
1916
  }
1884
- async pollSsmCommand(ssm, commandId, expectedCount) {
1917
+ async pollSsmCommand(ssm, commandId, expectedCount, maxWait = 600000) {
1885
1918
  const pollInterval = 3000;
1886
- const maxWait = 600000;
1887
1919
  const startTime = Date.now();
1888
1920
  const terminalStatuses = new Set(["Success", "Failed", "Cancelled", "TimedOut"]);
1889
1921
  let lastInvocations = [];
@@ -2019,9 +2051,21 @@ class HetznerClient {
2019
2051
  }
2020
2052
  return data;
2021
2053
  }
2054
+ async requestAll(path, key) {
2055
+ const items = [];
2056
+ let page = 1;
2057
+ for (;; ) {
2058
+ const sep = path.includes("?") ? "&" : "?";
2059
+ const data = await this.request("GET", `${path}${sep}per_page=50&page=${page}`);
2060
+ items.push(...data[key] ?? []);
2061
+ const next = data.meta?.pagination?.next_page;
2062
+ if (!next)
2063
+ return items;
2064
+ page = next;
2065
+ }
2066
+ }
2022
2067
  async listServers() {
2023
- const data = await this.request("GET", "/servers");
2024
- return data.servers;
2068
+ return this.requestAll("/servers", "servers");
2025
2069
  }
2026
2070
  async getServer(id) {
2027
2071
  const data = await this.request("GET", `/servers/${id}`);
@@ -2044,8 +2088,7 @@ class HetznerClient {
2044
2088
  return { server: data.server, action: data.action };
2045
2089
  }
2046
2090
  async listNetworks() {
2047
- const data = await this.request("GET", "/networks");
2048
- return data.networks;
2091
+ return this.requestAll("/networks", "networks");
2049
2092
  }
2050
2093
  async createNetwork(options) {
2051
2094
  const ipRange = options.ipRange ?? "10.0.0.0/16";
@@ -2061,8 +2104,7 @@ class HetznerClient {
2061
2104
  await this.request("DELETE", `/networks/${id}`);
2062
2105
  }
2063
2106
  async listLoadBalancers() {
2064
- const data = await this.request("GET", "/load_balancers");
2065
- return data.load_balancers;
2107
+ return this.requestAll("/load_balancers", "load_balancers");
2066
2108
  }
2067
2109
  async createLoadBalancer(options) {
2068
2110
  const data = await this.request("POST", "/load_balancers", {
@@ -2097,8 +2139,7 @@ class HetznerClient {
2097
2139
  return data.action;
2098
2140
  }
2099
2141
  async listFirewalls() {
2100
- const data = await this.request("GET", "/firewalls");
2101
- return data.firewalls;
2142
+ return this.requestAll("/firewalls", "firewalls");
2102
2143
  }
2103
2144
  async createFirewall(options) {
2104
2145
  const data = await this.request("POST", "/firewalls", {
@@ -2125,8 +2166,7 @@ class HetznerClient {
2125
2166
  return data.actions;
2126
2167
  }
2127
2168
  async listSshKeys() {
2128
- const data = await this.request("GET", "/ssh_keys");
2129
- return data.ssh_keys;
2169
+ return this.requestAll("/ssh_keys", "ssh_keys");
2130
2170
  }
2131
2171
  async createSshKey(options) {
2132
2172
  const data = await this.request("POST", "/ssh_keys", {
@@ -2223,6 +2263,19 @@ function buildFleetServicesEnv(servicesPrivateIp, database) {
2223
2263
  }
2224
2264
  return env2;
2225
2265
  }
2266
+ function buildFleetServicesBoxProvision(config) {
2267
+ const compute = config.infrastructure?.compute ?? {};
2268
+ const appDatabase = resolveAppDatabase(config);
2269
+ return [
2270
+ ...buildServicesProvisionScript(compute.managedServices ?? { mysql: true, redis: true }, { bindPrivate: true }),
2271
+ ...buildDatabaseSetupScript(appDatabase, compute.managedServices ?? { mysql: true }),
2272
+ ...buildAutoUpdatesScript(true),
2273
+ ...buildMonitoringScript(true),
2274
+ ...buildAuthorizedKeysScript(compute.sshKeys),
2275
+ ...buildNotifierScript(config.notifications),
2276
+ ...compute.backups?.enabled ? [...buildBackupProvisionScript({ database: appDatabase, backups: compute.backups })] : []
2277
+ ];
2278
+ }
2226
2279
 
2227
2280
  // src/drivers/hetzner/firewall-rules.ts
2228
2281
  function buildHetznerFirewallRules(config) {
@@ -2274,7 +2327,7 @@ function matchesTsCloudLabels(labels, slug, environment, role = "app") {
2274
2327
  }
2275
2328
 
2276
2329
  // src/drivers/hetzner/state.ts
2277
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2330
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2278
2331
  import { join as join3 } from "node:path";
2279
2332
  var STATE_DIR = "storage/cloud/state";
2280
2333
  function driverStatePath(stackName) {
@@ -2291,8 +2344,10 @@ async function readDriverState(stackName) {
2291
2344
  async function writeDriverState(stackName, state) {
2292
2345
  const path = driverStatePath(stackName);
2293
2346
  await mkdir(join3(process.cwd(), STATE_DIR), { recursive: true });
2294
- await writeFile(path, `${JSON.stringify(state, null, 2)}
2347
+ const tmp = `${path}.${process.pid}.tmp`;
2348
+ await writeFile(tmp, `${JSON.stringify(state, null, 2)}
2295
2349
  `, "utf8");
2350
+ await rename(tmp, path);
2296
2351
  }
2297
2352
 
2298
2353
  // src/drivers/hetzner/driver.ts
@@ -2300,7 +2355,7 @@ var SSH_MAX_BUFFER = 1024 * 1024 * 256;
2300
2355
  var SSH_ERROR_OUTPUT_LIMIT = 8000;
2301
2356
  function sshErrorOutput(value) {
2302
2357
  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);
2358
+ 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
2359
  }
2305
2360
  function formatSshFailure(error) {
2306
2361
  const childError = error;
@@ -2358,6 +2413,15 @@ class HetznerDriver {
2358
2413
  if (topology.dedicatedServices || topology.appServers > 1) {
2359
2414
  return phpBox ? this.provisionFleet(options, topology) : this.provisionBunFleet(options, topology);
2360
2415
  }
2416
+ const labels = tsCloudLabels(slug, environment, "app");
2417
+ const sites = config.sites || {};
2418
+ const sitePorts = this.collectUpstreamPorts(sites);
2419
+ const firewallName = `${slug}-${environment}-app-fw`;
2420
+ const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
2421
+ allowSsh: compute.allowSsh !== false,
2422
+ sitePorts
2423
+ }));
2424
+ const sshKeyId = await this.ensureSshKey(slug, environment, labels);
2361
2425
  const existing = await readDriverState(stackName);
2362
2426
  if (existing?.serverId) {
2363
2427
  const server2 = await this.tryGetServer(existing.serverId);
@@ -2365,7 +2429,6 @@ class HetznerDriver {
2365
2429
  return this.outputsFromState(existing, server2);
2366
2430
  }
2367
2431
  }
2368
- const labels = tsCloudLabels(slug, environment, "app");
2369
2432
  const alreadyRunning = await this.findExistingServer(slug, environment, serverName);
2370
2433
  if (alreadyRunning && alreadyRunning.status !== "off") {
2371
2434
  const rehydrated = {
@@ -2380,8 +2443,6 @@ class HetznerDriver {
2380
2443
  await writeDriverState(stackName, rehydrated);
2381
2444
  return this.outputsFromState(rehydrated, alreadyRunning);
2382
2445
  }
2383
- const sites = config.sites || {};
2384
- const sitePorts = this.collectUpstreamPorts(sites);
2385
2446
  const rpxProvision = compute.proxy?.engine === "rpx" ? buildRpxProvisionScript({
2386
2447
  proxy: compute.proxy,
2387
2448
  config: buildRpxConfig(sites, { proxy: compute.proxy, slug: config.project.slug }),
@@ -2403,12 +2464,6 @@ class HetznerDriver {
2403
2464
  const userData = wrapCloudInitUserData(bootstrap);
2404
2465
  const serverType = resolveHetznerServerType(compute.size);
2405
2466
  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
2467
  const { server, action } = await this.client.createServer({
2413
2468
  name: serverName,
2414
2469
  serverType,
@@ -2474,13 +2529,7 @@ class HetznerDriver {
2474
2529
  { direction: "in", protocol: "tcp", port: "6379", source_ips: [network.ip_range] },
2475
2530
  { direction: "in", protocol: "tcp", port: "7700", source_ips: [network.ip_range] }
2476
2531
  ]);
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
- ];
2532
+ const servicesProvision = buildFleetServicesBoxProvision(config);
2484
2533
  const servicesUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "php", servicesProvision, baked }));
2485
2534
  const all = await this.client.listServers().catch(() => []);
2486
2535
  const newServerIds = [];
@@ -2608,6 +2657,9 @@ class HetznerDriver {
2608
2657
  if (existingState?.lbServerId) {
2609
2658
  const lb = await this.tryGetServer(existingState.lbServerId);
2610
2659
  if (lb && lb.status !== "off") {
2660
+ const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
2661
+ const appTargets = await this.findComputeTargets({ slug, environment, role: "app", stackName });
2662
+ this.refreshLbRoutes(lb, sites, appTargets.map((t) => ({ privateIp: t.privateIp, publicIp: t.publicIp })), rpxProxy, slug);
2611
2663
  return {
2612
2664
  appPublicIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
2613
2665
  loadBalancerIp: lb.public_net.ipv4?.ip ?? existingState.publicIp,
@@ -2638,7 +2690,8 @@ class HetznerDriver {
2638
2690
  let servicesServerId;
2639
2691
  let servicesPrivateIp;
2640
2692
  const wantsServicesBox = !!compute.servicesServer || !!compute.managedServices;
2641
- if (topology.dedicatedServices && wantsServicesBox) {
2693
+ const dedicatedServicesHere = topology.dedicatedServices && wantsServicesBox;
2694
+ if (dedicatedServicesHere) {
2642
2695
  const { firewall: svcFw } = await this.ensureFirewall(`${slug}-${environment}-services-fw`, tsCloudLabels(slug, environment, "services"), [
2643
2696
  { direction: "in", protocol: "tcp", port: "22", source_ips: ["0.0.0.0/0", "::/0"] },
2644
2697
  { direction: "in", protocol: "tcp", port: "3306", source_ips: [network.ip_range] },
@@ -2646,13 +2699,7 @@ class HetznerDriver {
2646
2699
  { direction: "in", protocol: "tcp", port: "6379", source_ips: [network.ip_range] },
2647
2700
  { direction: "in", protocol: "tcp", port: "7700", source_ips: [network.ip_range] }
2648
2701
  ]);
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
- ];
2702
+ const servicesProvision = buildFleetServicesBoxProvision(config);
2656
2703
  const servicesUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "bun", servicesProvision, baked }));
2657
2704
  let svcServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "services"));
2658
2705
  if (!svcServer) {
@@ -2674,7 +2721,11 @@ class HetznerDriver {
2674
2721
  servicesServerId = svcServer.id;
2675
2722
  servicesPrivateIp = svcServer.private_net?.[0]?.ip ?? (await this.client.getServer(servicesServerId)).private_net?.[0]?.ip;
2676
2723
  }
2677
- const appProvisionScripts = buildComputeProvisionScripts(config);
2724
+ const appBoxCompute = dedicatedServicesHere ? { ...compute, managedServices: undefined, backups: undefined } : compute;
2725
+ const appProvisionScripts = buildComputeProvisionScripts({
2726
+ ...config,
2727
+ infrastructure: { ...config.infrastructure, compute: appBoxCompute }
2728
+ });
2678
2729
  const appUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({
2679
2730
  runtime: appProvisionScripts.runtime,
2680
2731
  runtimeVersion: appProvisionScripts.runtimeVersion,
@@ -2729,8 +2780,8 @@ class HetznerDriver {
2729
2780
  let lbIp;
2730
2781
  if (topology.loadBalancer) {
2731
2782
  let lbServer = all.find((s) => matchesTsCloudLabels(s.labels, slug, environment, "lb"));
2783
+ const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
2732
2784
  if (!lbServer) {
2733
- const rpxProxy = compute.proxy?.engine === "rpx" ? compute.proxy : { engine: "rpx" };
2734
2785
  const lbRpxProvision = buildRpxProvisionScript({
2735
2786
  proxy: rpxProxy,
2736
2787
  config: buildRpxLbConfig(sites, appBoxes, { proxy: rpxProxy, slug }),
@@ -2763,6 +2814,8 @@ class HetznerDriver {
2763
2814
  await this.waitForCloudInit(ip);
2764
2815
  }
2765
2816
  }
2817
+ } else {
2818
+ this.refreshLbRoutes(lbServer, sites, appBoxes, rpxProxy, slug);
2766
2819
  }
2767
2820
  lbId = lbServer.id;
2768
2821
  lbIp = lbServer.public_net.ipv4?.ip;
@@ -2862,8 +2915,9 @@ class HetznerDriver {
2862
2915
  const stackName = resolveProjectStackName(options.config, options.environment);
2863
2916
  const state = await readDriverState(stackName);
2864
2917
  if (state?.serverId) {
2865
- const server = await this.client.getServer(state.serverId);
2866
- return this.outputsFromState(state, server);
2918
+ const server = await this.tryGetServer(state.serverId);
2919
+ if (server)
2920
+ return this.outputsFromState(state, server);
2867
2921
  }
2868
2922
  if (state?.lbServerId) {
2869
2923
  const lb = await this.tryGetServer(state.lbServerId);
@@ -3008,6 +3062,21 @@ class HetznerDriver {
3008
3062
  return null;
3009
3063
  }
3010
3064
  }
3065
+ refreshLbRoutes(lb, sites, appBoxes, proxy, slug) {
3066
+ const ip = lb.public_net.ipv4?.ip;
3067
+ if (!ip) {
3068
+ console.warn(`[ts-cloud] LB server '${lb.name}' has no public IP — skipping the rpx route refresh; its routes may be stale.`);
3069
+ return;
3070
+ }
3071
+ const routable = appBoxes.filter((box) => box.privateIp || box.publicIp);
3072
+ if (routable.length === 0) {
3073
+ console.warn(`[ts-cloud] No routable app boxes found for LB '${lb.name}' — leaving its current rpx routes untouched.`);
3074
+ return;
3075
+ }
3076
+ const config = buildRpxLbConfig(sites, routable, { proxy, slug });
3077
+ this.sshExec(ip, buildRpxFragmentRefreshScript({ config, slug }).join(`
3078
+ `));
3079
+ }
3011
3080
  async findExistingServer(slug, environment, serverName) {
3012
3081
  const servers = await this.client.listServers();
3013
3082
  const exact = servers.find((server) => matchesTsCloudLabels(server.labels, slug, environment, "app") || server.name === serverName);
@@ -3066,12 +3135,12 @@ class HetznerDriver {
3066
3135
  const start = Date.now();
3067
3136
  while (Date.now() - start < cloudInitTimeoutMs) {
3068
3137
  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;
3138
+ 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
3139
  if (/status:\s*error/.test(out))
3073
3140
  throw new Error(`cloud-init reported an error on ${host}:
3074
3141
  ${out}`);
3142
+ if (/status:\s*(?:done|degraded)/.test(out))
3143
+ return;
3075
3144
  } catch (err) {
3076
3145
  if (err instanceof Error && /cloud-init reported an error/.test(err.message))
3077
3146
  throw err;
@@ -3164,7 +3233,9 @@ class LocalBoxDriver {
3164
3233
  async uploadRelease(options) {
3165
3234
  return { artifactRef: options.localPath };
3166
3235
  }
3167
- async findComputeTargets(_options) {
3236
+ async findComputeTargets(options) {
3237
+ if ((options.role || "app") !== "app")
3238
+ return [];
3168
3239
  return [{ id: "localhost", name: "localhost", publicIp: "127.0.0.1", status: "running" }];
3169
3240
  }
3170
3241
  async runRemoteDeploy(options) {
@@ -3500,7 +3571,7 @@ class AwsBoxProvisioner {
3500
3571
  MinCount: 1,
3501
3572
  MaxCount: 1,
3502
3573
  SecurityGroupIds: [groupId],
3503
- UserData: btoa(buildBoxUserData(spec)),
3574
+ UserData: Buffer.from(buildBoxUserData(spec), "utf8").toString("base64"),
3504
3575
  TagSpecifications: [{ ResourceType: "instance", Tags: tags }]
3505
3576
  });
3506
3577
  const instanceId = result.Instances?.[0]?.InstanceId;
@@ -3717,16 +3788,19 @@ function buildSiteDeployScript(options) {
3717
3788
  const paths = releasePaths(base, releaseId);
3718
3789
  const unitBase = `${slug}-${siteName}`;
3719
3790
  const serviceName = `${unitBase}.service`;
3791
+ const tarball = releaseTarballTmpPath(slug, siteName, releaseId);
3720
3792
  const sharedPaths = [...new Set([".env", ...options.sharedPaths ?? []])];
3721
3793
  const envFile = formatEnvFile(envEntries);
3722
3794
  const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
3723
3795
  const stageRelease = [
3724
3796
  "set -euo pipefail",
3797
+ `trap 'if [ $? -ne 0 ] && [ "$(readlink -f ${paths.current} 2>/dev/null || true)" != "${paths.release}" ]; then rm -rf ${paths.release}; fi' EXIT`,
3725
3798
  ...artifactFetch,
3726
3799
  ...buildEnsureReleaseLayout(paths, sharedPaths),
3727
3800
  `rm -rf ${paths.release}`,
3728
3801
  `mkdir -p ${paths.release}`,
3729
- `tar xzf /tmp/${siteName}-release.tar.gz -C ${paths.release}`,
3802
+ `tar xzf ${tarball} -C ${paths.release}`,
3803
+ `rm -f ${tarball}`,
3730
3804
  `cat > ${paths.shared}/.env <<'TS_CLOUD_ENV_EOF'`,
3731
3805
  envFile,
3732
3806
  "TS_CLOUD_ENV_EOF",
@@ -3768,7 +3842,7 @@ function buildSiteDeployScript(options) {
3768
3842
  ...buildActivateRelease(paths),
3769
3843
  `systemctl enable ${instance} 2>/dev/null || true`,
3770
3844
  `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`,
3845
+ `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
3846
  `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
3847
  ...buildPruneReleases(paths, keepReleases)
3774
3848
  ];
@@ -3804,27 +3878,34 @@ function buildStaticSiteDeployScript(options) {
3804
3878
  const { siteName, artifactFetch, releaseId, keepReleases = DEFAULT_KEEP_RELEASES, preStartCommands = [] } = options;
3805
3879
  const base = options.appDir ?? `/var/www/${siteName}`;
3806
3880
  const paths = releasePaths(base, releaseId);
3881
+ const tarball = releaseTarballTmpPath(options.slug, siteName, releaseId);
3807
3882
  const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
3808
3883
  return [
3809
3884
  "set -euo pipefail",
3885
+ `trap 'if [ $? -ne 0 ] && [ "$(readlink -f ${paths.current} 2>/dev/null || true)" != "${paths.release}" ]; then rm -rf ${paths.release}; fi' EXIT`,
3810
3886
  ...artifactFetch,
3811
3887
  ...buildEnsureReleaseLayout(paths, []),
3812
3888
  `rm -rf ${paths.release}`,
3813
3889
  `mkdir -p ${paths.release}`,
3814
- `tar xzf /tmp/${siteName}-release.tar.gz -C ${paths.release}`,
3890
+ `tar xzf ${tarball} -C ${paths.release}`,
3891
+ `rm -f ${tarball}`,
3815
3892
  ...preStart,
3816
3893
  ...buildActivateRelease(paths),
3817
3894
  ...buildPruneReleases(paths, keepReleases)
3818
3895
  ];
3819
3896
  }
3820
- function buildAwsArtifactFetch(bucket, key, region, siteName) {
3897
+ function releaseTarballTmpPath(slug, siteName, releaseId) {
3898
+ const parts = [slug, siteName, releaseId].filter(Boolean).join("-");
3899
+ return `/tmp/${parts}-release.tar.gz`;
3900
+ }
3901
+ function buildAwsArtifactFetch(bucket, key, region, destPath) {
3821
3902
  return [
3822
- `aws s3 cp "s3://${bucket}/${key}" /tmp/${siteName}-release.tar.gz --region ${region}`
3903
+ `aws s3 cp "s3://${bucket}/${key}" ${destPath} --region ${region}`
3823
3904
  ];
3824
3905
  }
3825
- function buildLocalArtifactFetch(localPath, siteName) {
3906
+ function buildLocalArtifactFetch(localPath, destPath) {
3826
3907
  return [
3827
- `cp "${localPath}" /tmp/${siteName}-release.tar.gz`
3908
+ `cp "${localPath}" ${destPath}`
3828
3909
  ];
3829
3910
  }
3830
3911
  // src/drivers/shared/compute-deploy.ts
@@ -4088,7 +4169,7 @@ function buildCertbotInstallScript(dns) {
4088
4169
  "mkdir -p /etc/letsencrypt/renewal-hooks/deploy",
4089
4170
  "cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'TS_CLOUD_HOOK_EOF'",
4090
4171
  "#!/bin/sh",
4091
- "systemctl reload nginx",
4172
+ "systemctl reload ts-cloud-nginx 2>/dev/null || systemctl reload nginx 2>/dev/null || true",
4092
4173
  "TS_CLOUD_HOOK_EOF",
4093
4174
  "chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh",
4094
4175
  "systemctl enable certbot.timer 2>/dev/null || true",
@@ -4121,7 +4202,7 @@ function buildCertbotIssueScript(options) {
4121
4202
  args.push(`--${plugin}-propagation-seconds ${dns.propagationSeconds}`);
4122
4203
  args.push("certonly");
4123
4204
  } else {
4124
- args.push("--nginx", options.redirect === false ? "--no-redirect" : "--redirect");
4205
+ args.push("--nginx", `--nginx-ctl ${NGINX_WRAPPER}`, options.redirect === false ? "--no-redirect" : "--redirect");
4125
4206
  }
4126
4207
  if (options.email)
4127
4208
  args.push(`-m ${options.email}`);
@@ -4165,16 +4246,16 @@ function buildGitCheckoutScript(options) {
4165
4246
  ];
4166
4247
  if (repository.strategy === "tag") {
4167
4248
  if (repository.tag) {
4168
- lines.push(`git clone -q --depth 1 --branch ${repository.tag} ${url} ${releaseDir}`);
4249
+ lines.push(`git clone -q --depth 1 --branch ${shellQuote(repository.tag)} ${shellQuote(url)} ${releaseDir}`);
4169
4250
  lines.push(`printf '%s' ${shellQuote(repository.tag)} > ${releaseDir}/.ts-cloud-tag`);
4170
4251
  } else {
4171
4252
  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`);
4253
+ 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
4254
  }
4174
4255
  } 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`);
4256
+ 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
4257
  } else {
4177
- lines.push(`git clone -q --depth 1 --branch ${branch} ${url} ${releaseDir}`);
4258
+ lines.push(`git clone -q --depth 1 --branch ${shellQuote(branch)} ${shellQuote(url)} ${releaseDir}`);
4178
4259
  }
4179
4260
  lines.push(`git -C ${releaseDir} rev-parse HEAD > ${releaseDir}/.ts-cloud-sha`);
4180
4261
  return lines;
@@ -4590,12 +4671,13 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4590
4671
  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
4672
  return { success: false, error: hint };
4592
4673
  }
4674
+ const dbEnv = outputs.servicesPrivateIp ? buildFleetServicesEnv(outputs.servicesPrivateIp, resolveAppDatabase(config)) : buildManagedDbEnv(resolveAppDatabase(config));
4675
+ const envWithServices = Object.keys(dbEnv).length > 0 ? { ...dbEnv, ...site.env || {} } : site.env || {};
4593
4676
  if (isPhpSite(site)) {
4594
4677
  const compute2 = config.infrastructure?.compute;
4595
4678
  const phpVersion = site.phpVersion ?? compute2?.php?.default ?? compute2?.php?.versions?.[0];
4596
4679
  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;
4680
+ const siteWithEnv = Object.keys(dbEnv).length > 0 ? { ...site, env: envWithServices } : site;
4599
4681
  const deployScript = buildLaravelDeployScript({
4600
4682
  siteName,
4601
4683
  site: siteWithEnv,
@@ -4662,11 +4744,13 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4662
4744
  remoteKey,
4663
4745
  targets
4664
4746
  });
4665
- const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config.project.region || "us-east-1", siteName) : buildLocalArtifactFetch(uploadResult.artifactRef, siteName);
4747
+ const tarballPath = releaseTarballTmpPath(slug, siteName, sha);
4748
+ const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config.project.region || "us-east-1", tarballPath) : buildLocalArtifactFetch(uploadResult.artifactRef, tarballPath);
4666
4749
  const kind = resolveSiteKind(site);
4667
4750
  const appBase = siteInstallBase(slug, siteName);
4668
4751
  const baseScript = kind === "server-static" ? buildStaticSiteDeployScript({
4669
4752
  siteName,
4753
+ slug,
4670
4754
  appDir: appBase,
4671
4755
  artifactFetch,
4672
4756
  releaseId: sha,
@@ -4678,7 +4762,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4678
4762
  artifactFetch,
4679
4763
  releaseId: sha,
4680
4764
  execStart: resolveExecStart(site.start, runtime),
4681
- envEntries: site.env || {},
4765
+ envEntries: envWithServices,
4682
4766
  port: site.port,
4683
4767
  preStartCommands: site.preStart,
4684
4768
  sharedPaths: site.sharedPaths,
@@ -4708,7 +4792,7 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4708
4792
  ...buildDeployHistoryHeader(appBase, {
4709
4793
  releaseId: sha,
4710
4794
  commit: sha,
4711
- branch: site.branch ?? "main"
4795
+ branch: site.repository?.branch ?? "main"
4712
4796
  }),
4713
4797
  ...buildSiteOwnerGuard(appBase, slug),
4714
4798
  ...baseScript,
@@ -4741,6 +4825,44 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
4741
4825
  perInstance: result.perInstance
4742
4826
  };
4743
4827
  }
4828
+ async function ensureAttachModeDatabase(driver, options, logger) {
4829
+ const { config, environment } = options;
4830
+ if (!config.cloud?.attachTo)
4831
+ return true;
4832
+ const compute = config.infrastructure?.compute;
4833
+ const database = resolveAppDatabase(config);
4834
+ const commands = buildDatabaseSetupScript(database, compute?.managedServices ?? {});
4835
+ if (commands.length === 0)
4836
+ return true;
4837
+ const slug = config.project.slug;
4838
+ const stackName = resolveProjectStackName(config, environment);
4839
+ const targets = await driver.findComputeTargets({
4840
+ slug,
4841
+ environment,
4842
+ role: "app",
4843
+ stackName
4844
+ });
4845
+ if (targets.length === 0) {
4846
+ return true;
4847
+ }
4848
+ logger.step(`Ensuring database '${database?.name}' + role exist on the shared box...`);
4849
+ const result = await driver.runRemoteDeploy({
4850
+ targets,
4851
+ commands,
4852
+ comment: `ts-cloud ensure database ${slug}/${database?.name}`,
4853
+ tags: {
4854
+ Project: slug,
4855
+ Environment: environment,
4856
+ Role: "app"
4857
+ }
4858
+ });
4859
+ if (!result.success) {
4860
+ logger.error(`Ensuring database '${database?.name}' failed: ${result.error || "unknown error"}`);
4861
+ return false;
4862
+ }
4863
+ logger.success(`Database '${database?.name}' is ready on ${result.instanceCount} target(s)`);
4864
+ return true;
4865
+ }
4744
4866
  async function deployAllComputeSites(options) {
4745
4867
  const { config, environment, driver, sha, runtime, tarballForSite, logger = noopLogger2 } = options;
4746
4868
  const slug = config.project.slug;
@@ -4791,6 +4913,8 @@ async function deployAllComputeSites(options) {
4791
4913
  });
4792
4914
  if (deployable.length === 0)
4793
4915
  return true;
4916
+ if (!await ensureAttachModeDatabase(driver, options, logger))
4917
+ return false;
4794
4918
  const tarballFor = (siteName) => dashboardTarballBySite.get(siteName) ?? tarballForSite(siteName);
4795
4919
  for (const [siteName, site] of deployable) {
4796
4920
  logger.step(`Deploying site: ${siteName}`);
@@ -4833,29 +4957,68 @@ async function reloadRpxGateway(options) {
4833
4957
  if (proxy?.engine !== "rpx")
4834
4958
  return true;
4835
4959
  const sites = routeSource.sites || {};
4836
- const rpxConfig = buildRpxConfig(sites, { proxy, slug: config.project.slug });
4960
+ const slug = config.project.slug;
4961
+ const stackName = resolveProjectStackName(config, environment);
4962
+ const lbTargets = await driver.findComputeTargets({
4963
+ slug,
4964
+ environment,
4965
+ role: "lb",
4966
+ stackName
4967
+ });
4968
+ if (lbTargets.length > 0) {
4969
+ const appTargets = await driver.findComputeTargets({
4970
+ slug,
4971
+ environment,
4972
+ role: "app",
4973
+ stackName
4974
+ });
4975
+ const appBoxes = appTargets.map((t) => ({ privateIp: t.privateIp, publicIp: t.publicIp }));
4976
+ const lbConfig = buildRpxLbConfig(sites, appBoxes, { proxy, slug });
4977
+ if (lbConfig.proxies.length === 0) {
4978
+ logger.warn("rpx gateway: no server sites with a domain to route — skipping gateway reload.");
4979
+ return true;
4980
+ }
4981
+ logger.step(`Reloading the load balancer's rpx gateway with ${lbConfig.proxies.length} route(s)...`);
4982
+ const result2 = await driver.runRemoteDeploy({
4983
+ targets: lbTargets,
4984
+ commands: buildRpxFragmentRefreshScript({ config: lbConfig, slug }),
4985
+ comment: `ts-cloud rpx gateway reload ${slug}`,
4986
+ tags: {
4987
+ Project: slug,
4988
+ Environment: environment,
4989
+ Role: "lb"
4990
+ }
4991
+ });
4992
+ if (!result2.success) {
4993
+ logger.error(`rpx gateway reload failed: ${result2.error || "unknown error"}`);
4994
+ return false;
4995
+ }
4996
+ logger.success(`rpx gateway reloaded on ${result2.instanceCount} load balancer(s)`);
4997
+ return true;
4998
+ }
4999
+ const rpxConfig = buildRpxConfig(sites, { proxy, slug });
4837
5000
  if (rpxConfig.proxies.length === 0) {
4838
5001
  logger.warn("rpx gateway: no server sites with a domain to route — skipping gateway reload.");
4839
5002
  return true;
4840
5003
  }
4841
5004
  const targets = await driver.findComputeTargets({
4842
- slug: config.project.slug,
5005
+ slug,
4843
5006
  environment,
4844
5007
  role: "app",
4845
- stackName: resolveProjectStackName(config, environment)
5008
+ stackName
4846
5009
  });
4847
5010
  if (targets.length === 0) {
4848
5011
  logger.warn("rpx gateway: no compute targets found — skipping gateway reload.");
4849
5012
  return true;
4850
5013
  }
4851
5014
  logger.step(`Reloading rpx gateway with ${rpxConfig.proxies.length} route(s)...`);
4852
- const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug: config.project.slug });
5015
+ const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug });
4853
5016
  const result = await driver.runRemoteDeploy({
4854
5017
  targets,
4855
5018
  commands: script,
4856
- comment: `ts-cloud rpx gateway reload ${config.project.slug}`,
5019
+ comment: `ts-cloud rpx gateway reload ${slug}`,
4857
5020
  tags: {
4858
- Project: config.project.slug,
5021
+ Project: slug,
4859
5022
  Environment: environment,
4860
5023
  Role: "app"
4861
5024
  }
@@ -4947,4 +5110,4 @@ function buildCloudFrontOriginConfig(options) {
4947
5110
  CustomErrorResponses: { Quantity: 0 }
4948
5111
  };
4949
5112
  }
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 };
5113
+ export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, pgAdminCommand, 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 };