@stacksjs/ts-cloud 0.2.21 → 0.2.22

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.
package/dist/index.js CHANGED
@@ -80931,6 +80931,77 @@ class AcmeClient {
80931
80931
  return this.accountKey;
80932
80932
  }
80933
80933
  }
80934
+ // src/deploy/site-target.ts
80935
+ function resolveSiteDeployTarget(site) {
80936
+ if (site.deploy)
80937
+ return site.deploy;
80938
+ if (site.start)
80939
+ return "server";
80940
+ return "bucket";
80941
+ }
80942
+ function resolveSiteKind(site) {
80943
+ const target = resolveSiteDeployTarget(site);
80944
+ if (target === "bucket")
80945
+ return "bucket";
80946
+ return site.start ? "server-app" : "server-static";
80947
+ }
80948
+ function hasComputeConfigured(config6) {
80949
+ return config6.infrastructure?.compute != null;
80950
+ }
80951
+ function validateDeploymentConfig(config6) {
80952
+ const errors = [];
80953
+ const warnings = [];
80954
+ const sites = config6.sites || {};
80955
+ const computeConfigured = hasComputeConfigured(config6);
80956
+ const portOwners = new Map;
80957
+ for (const [name, site] of Object.entries(sites)) {
80958
+ if (!site) {
80959
+ continue;
80960
+ }
80961
+ const target = resolveSiteDeployTarget(site);
80962
+ const kind = resolveSiteKind(site);
80963
+ if (target === "server" && !site.start && !site.root) {
80964
+ errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
80965
+ continue;
80966
+ }
80967
+ if (kind === "server-app") {
80968
+ if (!computeConfigured) {
80969
+ errors.push(`Site '${name}' deploys to a server (deploy:'server'${site.deploy ? "" : " inferred from `start`"}) but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
80970
+ }
80971
+ if (typeof site.port === "number") {
80972
+ const existing = portOwners.get(site.port);
80973
+ if (existing) {
80974
+ errors.push(`Sites '${existing}' and '${name}' both use port ${site.port}. Server apps sharing a box must use distinct ports.`);
80975
+ } else {
80976
+ portOwners.set(site.port, name);
80977
+ }
80978
+ }
80979
+ } else if (kind === "server-static") {
80980
+ if (!site.root) {
80981
+ errors.push(`Site '${name}' is a server static site (deploy:'server', no \`start\`) but has no \`root\` directory to serve.`);
80982
+ }
80983
+ if (!computeConfigured) {
80984
+ errors.push(`Site '${name}' deploys to a server (deploy:'server') but no \`infrastructure.compute\` is configured. Set deploy:'bucket' or add a server (infrastructure.compute).`);
80985
+ }
80986
+ } else {
80987
+ if (!site.root) {
80988
+ errors.push(`Site '${name}' deploys to a bucket but has no \`root\` directory to upload.`);
80989
+ }
80990
+ const serverOnly = [];
80991
+ if (site.start)
80992
+ serverOnly.push("start");
80993
+ if (typeof site.port === "number")
80994
+ serverOnly.push("port");
80995
+ if (site.preStart && site.preStart.length > 0)
80996
+ serverOnly.push("preStart");
80997
+ if (serverOnly.length > 0) {
80998
+ warnings.push(`Site '${name}' deploys to a bucket but sets server-only field(s): ${serverOnly.join(", ")}. These are ignored. Set deploy:'server' to use them.`);
80999
+ }
81000
+ }
81001
+ }
81002
+ return { errors, warnings };
81003
+ }
81004
+
80934
81005
  // src/deploy/index.ts
80935
81006
  init_static_site();
80936
81007
  init_static_site_external_dns();
@@ -81159,46 +81230,202 @@ import { join as join13 } from "node:path";
81159
81230
  import { execSync } from "node:child_process";
81160
81231
 
81161
81232
  // src/drivers/shared/caddyfile.ts
81162
- function buildCaddyfile(sites) {
81163
- const allSites = Object.entries(sites);
81164
- const sitesWithDomain = allSites.filter(([, s]) => typeof s.domain === "string" && s.domain && typeof s.port === "number");
81165
- if (sitesWithDomain.length === 0)
81233
+ function isOnDemandDomain(domain) {
81234
+ return domain === "*" || domain.includes("*");
81235
+ }
81236
+ function isStaticApp(app) {
81237
+ return typeof app.root === "string" && app.root.length > 0;
81238
+ }
81239
+ function isProxyApp(app) {
81240
+ return typeof app.port === "number";
81241
+ }
81242
+ function staticSiteServerRoot(name) {
81243
+ return `/var/www/${name}`;
81244
+ }
81245
+ function normalizeOnDemandTls(onDemandTls) {
81246
+ if (!onDemandTls)
81247
+ return;
81248
+ if (onDemandTls === true)
81249
+ return {};
81250
+ return onDemandTls;
81251
+ }
81252
+ function wrapInHandle(body, path, indent) {
81253
+ const inner = body.split(`
81254
+ `).map((line) => ` ${line}`).join(`
81255
+ `);
81256
+ return `${indent}handle ${path} {
81257
+ ${inner}
81258
+ ${indent}}`;
81259
+ }
81260
+ function renderUpstreamBlock(app, indent) {
81261
+ const host = app.upstreamHost || "localhost";
81262
+ const upstream = `${host}:${app.port}`;
81263
+ const directives = app.reverseProxyDirectives ?? [];
81264
+ const proxyLine = directives.length === 0 ? `${indent}reverse_proxy ${upstream}` : [
81265
+ `${indent}reverse_proxy ${upstream} {`,
81266
+ ...directives.map((d) => `${indent} ${d}`),
81267
+ `${indent}}`
81268
+ ].join(`
81269
+ `);
81270
+ const isCatchAll = !app.path || app.path === "/";
81271
+ if (isCatchAll)
81272
+ return proxyLine;
81273
+ return wrapInHandle(proxyLine, app.path, indent);
81274
+ }
81275
+ function renderStaticBlock(app, indent) {
81276
+ const root = app.root;
81277
+ const lines = [`${indent}root * ${root}`];
81278
+ if (app.cache?.enabled) {
81279
+ const maxAge = app.cache.maxAge ?? 3600;
81280
+ lines.push(`${indent}header Cache-Control "public, max-age=${maxAge}"`);
81281
+ }
81282
+ if (app.spa) {
81283
+ lines.push(`${indent}try_files {path} /index.html`);
81284
+ } else if (app.pathRewriteStyle === "flat") {
81285
+ lines.push(`${indent}try_files {path} {path}.html {path}/index.html`);
81286
+ } else {
81287
+ lines.push(`${indent}try_files {path} {path}/index.html {path}.html`);
81288
+ }
81289
+ lines.push(`${indent}file_server`);
81290
+ const body = lines.join(`
81291
+ `);
81292
+ const isCatchAll = !app.path || app.path === "/";
81293
+ if (isCatchAll)
81294
+ return body;
81295
+ return wrapInHandle(body, app.path, indent);
81296
+ }
81297
+ function renderAppBlock(app, indent) {
81298
+ return isStaticApp(app) ? renderStaticBlock(app, indent) : renderUpstreamBlock(app, indent);
81299
+ }
81300
+ function groupAppsByDomains(apps) {
81301
+ const groups = new Map;
81302
+ for (const app of apps) {
81303
+ const domains = [...app.domains].filter(Boolean);
81304
+ if (domains.length === 0)
81305
+ continue;
81306
+ const key = [...domains].sort().join(" ");
81307
+ const group = groups.get(key) ?? { domains, apps: [] };
81308
+ group.apps.push(app);
81309
+ groups.set(key, group);
81310
+ }
81311
+ return [...groups.values()];
81312
+ }
81313
+ function sortAppsByPath(apps) {
81314
+ return [...apps].sort((a, b) => {
81315
+ const aCatchAll = !a.path || a.path === "/";
81316
+ const bCatchAll = !b.path || b.path === "/";
81317
+ if (aCatchAll && !bCatchAll)
81318
+ return 1;
81319
+ if (!aCatchAll && bCatchAll)
81320
+ return -1;
81321
+ return (b.path?.length ?? 0) - (a.path?.length ?? 0);
81322
+ });
81323
+ }
81324
+ function buildCaddyfileFromProxy(proxy) {
81325
+ if (proxy.raw && proxy.raw.trim())
81326
+ return proxy.raw.trim();
81327
+ const apps = (proxy.apps ?? []).filter((app) => app.domains.length > 0 && (isProxyApp(app) || isStaticApp(app)));
81328
+ if (apps.length === 0)
81166
81329
  return;
81167
- const byDomain = new Map;
81168
- for (const [, site] of sitesWithDomain) {
81169
- const list = byDomain.get(site.domain) ?? [];
81170
- list.push({ port: site.port, path: site.path });
81171
- byDomain.set(site.domain, list);
81330
+ const onDemand = normalizeOnDemandTls(proxy.onDemandTls);
81331
+ const hasOnDemandDomain = apps.some((app) => app.domains.some(isOnDemandDomain));
81332
+ const globalLines = [];
81333
+ if (proxy.email)
81334
+ globalLines.push(`email ${proxy.email}`);
81335
+ if (proxy.staging)
81336
+ globalLines.push("acme_ca https://acme-staging-v02.api.letsencrypt.org/directory");
81337
+ if (onDemand) {
81338
+ if (onDemand.ask || onDemand.interval || onDemand.burst != null) {
81339
+ const inner = [];
81340
+ if (onDemand.ask)
81341
+ inner.push(` ask ${onDemand.ask}`);
81342
+ if (onDemand.interval || onDemand.burst != null) {
81343
+ const rl = [];
81344
+ if (onDemand.interval)
81345
+ rl.push(` interval ${onDemand.interval}`);
81346
+ if (onDemand.burst != null)
81347
+ rl.push(` burst ${onDemand.burst}`);
81348
+ inner.push(` rate_limit {
81349
+ ${rl.join(`
81350
+ `)}
81351
+ }`);
81352
+ }
81353
+ globalLines.push(`on_demand_tls {
81354
+ ${inner.join(`
81355
+ `)}
81356
+ }`);
81357
+ } else {
81358
+ globalLines.push(`on_demand_tls {
81359
+ }`);
81360
+ }
81172
81361
  }
81362
+ for (const directive of proxy.globalDirectives ?? [])
81363
+ globalLines.push(directive);
81173
81364
  const blocks = [];
81174
- for (const [domain, domainSites] of byDomain) {
81175
- const sorted = [...domainSites].sort((a, b) => {
81176
- const aIsCatchAll = !a.path || a.path === "/";
81177
- const bIsCatchAll = !b.path || b.path === "/";
81178
- if (aIsCatchAll && !bIsCatchAll)
81179
- return 1;
81180
- if (!aIsCatchAll && bIsCatchAll)
81181
- return -1;
81182
- return (b.path?.length ?? 0) - (a.path?.length ?? 0);
81183
- });
81184
- const handles = sorted.map((s) => {
81185
- const isCatchAll = !s.path || s.path === "/";
81186
- const inner = `reverse_proxy localhost:${s.port}`;
81187
- return isCatchAll ? ` handle {
81188
- ${inner}
81189
- }` : ` handle ${s.path} {
81190
- ${inner}
81191
- }`;
81192
- });
81193
- blocks.push(`${domain} {
81194
- ${handles.join(`
81365
+ if (globalLines.length > 0)
81366
+ blocks.push(`{
81367
+ ${globalLines.map((line) => ` ${line}`).join(`
81195
81368
  `)}
81196
81369
  }`);
81370
+ for (const group of groupAppsByDomains(apps)) {
81371
+ const sorted = sortAppsByPath(group.apps);
81372
+ const body = sorted.map((app) => renderAppBlock(app, " ")).join(`
81373
+ `);
81374
+ const needsOnDemand = onDemand && group.domains.some(isOnDemandDomain);
81375
+ const tlsBlock = needsOnDemand ? `
81376
+ tls {
81377
+ on_demand
81378
+ }` : "";
81379
+ blocks.push(`${group.domains.join(", ")} {
81380
+ ${body}${tlsBlock}
81381
+ }`);
81382
+ }
81383
+ if (hasOnDemandDomain && !onDemand) {
81384
+ blocks.unshift(`# WARNING: wildcard/catch-all domain present but on_demand_tls is not enabled.
81385
+ ` + "# Caddy cannot provision TLS for these hosts. Set compute.proxy.onDemandTls.");
81197
81386
  }
81198
81387
  return blocks.join(`
81199
81388
 
81200
81389
  `);
81201
81390
  }
81391
+ function proxyConfigFromSites(sites) {
81392
+ const apps = [];
81393
+ for (const [name, site] of Object.entries(sites)) {
81394
+ if (typeof site.domain !== "string" || !site.domain)
81395
+ continue;
81396
+ if (typeof site.port === "number" && site.deploy !== "bucket") {
81397
+ apps.push({
81398
+ name,
81399
+ domains: [site.domain],
81400
+ port: site.port,
81401
+ path: site.path
81402
+ });
81403
+ } else if (resolveSiteKind(site) === "server-static") {
81404
+ apps.push({
81405
+ name,
81406
+ domains: [site.domain],
81407
+ root: staticSiteServerRoot(name),
81408
+ path: site.path,
81409
+ spa: site.spa,
81410
+ pathRewriteStyle: site.pathRewriteStyle,
81411
+ cache: site.cache
81412
+ });
81413
+ }
81414
+ }
81415
+ return { apps };
81416
+ }
81417
+ function resolveCaddyfile(sites, proxy) {
81418
+ if (proxy) {
81419
+ if (proxy.raw && proxy.raw.trim())
81420
+ return proxy.raw.trim();
81421
+ const resolved = proxy.apps && proxy.apps.length > 0 ? proxy : { ...proxy, apps: proxyConfigFromSites(sites).apps };
81422
+ return buildCaddyfileFromProxy(resolved);
81423
+ }
81424
+ return buildCaddyfileFromProxy(proxyConfigFromSites(sites));
81425
+ }
81426
+ function buildCaddyfile(sites) {
81427
+ return buildCaddyfileFromProxy(proxyConfigFromSites(sites));
81428
+ }
81202
81429
 
81203
81430
  // src/drivers/hetzner/client.ts
81204
81431
  var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
@@ -81223,10 +81450,20 @@ class HetznerClient {
81223
81450
  body: body === undefined ? undefined : JSON.stringify(body)
81224
81451
  });
81225
81452
  const text = await response.text();
81226
- const data = text ? JSON.parse(text) : {};
81453
+ let data;
81454
+ try {
81455
+ data = text ? JSON.parse(text) : {};
81456
+ } catch {
81457
+ if (!response.ok) {
81458
+ const snippet = text.trim().slice(0, 200) || response.statusText || "Hetzner API error";
81459
+ throw new Error(`Hetzner API ${method} ${path} (${response.status}): ${snippet}`);
81460
+ }
81461
+ throw new Error(`Hetzner API ${method} ${path}: unexpected non-JSON response`);
81462
+ }
81227
81463
  if (!response.ok) {
81228
81464
  const message = data.error?.message || response.statusText || "Hetzner API error";
81229
- throw new Error(`Hetzner API ${method} ${path}: ${message}`);
81465
+ const code = data.error?.code ? ` [${data.error.code}]` : "";
81466
+ throw new Error(`Hetzner API ${method} ${path} (${response.status})${code}: ${message}`);
81230
81467
  }
81231
81468
  return data;
81232
81469
  }
@@ -81270,6 +81507,12 @@ class HetznerClient {
81270
81507
  });
81271
81508
  return { firewall: data.firewall, actions: data.actions };
81272
81509
  }
81510
+ async setFirewallRules(firewallId, rules) {
81511
+ const data = await this.request("POST", `/firewalls/${firewallId}/actions/set_rules`, {
81512
+ rules
81513
+ });
81514
+ return data.actions ?? [];
81515
+ }
81273
81516
  async applyFirewallToResources(firewallId, applyTo) {
81274
81517
  const data = await this.request("POST", `/firewalls/${firewallId}/actions/apply_to_resources`, {
81275
81518
  apply_to: applyTo
@@ -81536,6 +81779,8 @@ class HetznerDriver {
81536
81779
  sshPublicKeyPath;
81537
81780
  sshUser;
81538
81781
  location;
81782
+ waitForBoot;
81783
+ bootWait;
81539
81784
  constructor(options = {}) {
81540
81785
  this.client = options.client ?? new HetznerClient({
81541
81786
  apiToken: resolveHetznerApiToken(options.apiToken)
@@ -81544,6 +81789,13 @@ class HetznerDriver {
81544
81789
  this.sshPublicKeyPath = expandHome(options.sshPublicKeyPath || process.env.HCLOUD_SSH_PUBLIC_KEY || `${this.sshPrivateKeyPath}.pub`);
81545
81790
  this.sshUser = options.sshUser || process.env.HCLOUD_SSH_USER || "root";
81546
81791
  this.location = options.location || process.env.HCLOUD_LOCATION || "fsn1";
81792
+ this.waitForBoot = options.waitForBoot ?? true;
81793
+ this.bootWait = {
81794
+ sshIntervalMs: options.bootWait?.sshIntervalMs ?? 5000,
81795
+ sshTimeoutMs: options.bootWait?.sshTimeoutMs ?? 300000,
81796
+ cloudInitIntervalMs: options.bootWait?.cloudInitIntervalMs ?? 5000,
81797
+ cloudInitTimeoutMs: options.bootWait?.cloudInitTimeoutMs ?? 600000
81798
+ };
81547
81799
  }
81548
81800
  async provisionComputeInfrastructure(options) {
81549
81801
  const { config: config6, environment } = options;
@@ -81553,16 +81805,32 @@ class HetznerDriver {
81553
81805
  throw new Error("infrastructure.compute is required to provision Hetzner compute");
81554
81806
  }
81555
81807
  const stackName = resolveProjectStackName(config6, environment);
81808
+ const serverName = `${slug}-${environment}-app`;
81556
81809
  const existing = await readDriverState(stackName);
81557
81810
  if (existing?.serverId) {
81558
- const server2 = await this.client.getServer(existing.serverId);
81559
- if (server2.status !== "off") {
81811
+ const server2 = await this.tryGetServer(existing.serverId);
81812
+ if (server2 && server2.status !== "off") {
81560
81813
  return this.outputsFromState(existing, server2);
81561
81814
  }
81562
81815
  }
81816
+ const labels = tsCloudLabels(slug, environment, "app");
81817
+ const alreadyRunning = await this.findExistingServer(slug, environment, serverName);
81818
+ if (alreadyRunning && alreadyRunning.status !== "off") {
81819
+ const rehydrated = {
81820
+ provider: "hetzner",
81821
+ stackName,
81822
+ serverId: alreadyRunning.id,
81823
+ serverName: alreadyRunning.name,
81824
+ publicIp: alreadyRunning.public_net.ipv4?.ip,
81825
+ deployStoragePath: "/var/ts-cloud/staging",
81826
+ sshUser: this.sshUser
81827
+ };
81828
+ await writeDriverState(stackName, rehydrated);
81829
+ return this.outputsFromState(rehydrated, alreadyRunning);
81830
+ }
81563
81831
  const sites = config6.sites || {};
81564
- const sitePorts = Object.values(sites).map((site) => site.port).filter((port) => typeof port === "number" && ![80, 443].includes(port));
81565
- const caddyfile = buildCaddyfile(sites);
81832
+ const caddyfile = resolveCaddyfile(sites, compute.proxy);
81833
+ const sitePorts = caddyfile ? [] : this.collectUpstreamPorts(sites, compute.proxy);
81566
81834
  const bootstrap = generateUbuntuAppCloudInit({
81567
81835
  runtime: compute.runtime || "bun",
81568
81836
  runtimeVersion: compute.runtimeVersion || "latest",
@@ -81571,19 +81839,13 @@ class HetznerDriver {
81571
81839
  caddyfile
81572
81840
  });
81573
81841
  const userData = wrapCloudInitUserData(bootstrap);
81574
- const serverName = `${slug}-${environment}-app`;
81575
81842
  const serverType = resolveHetznerServerType(compute.size);
81576
81843
  const image = compute.image || config6.hetzner?.image || "ubuntu-24.04";
81577
- const labels = tsCloudLabels(slug, environment, "app");
81578
81844
  const firewallName = `${slug}-${environment}-app-fw`;
81579
- const { firewall } = await this.client.createFirewall({
81580
- name: firewallName,
81581
- labels,
81582
- rules: buildHetznerFirewallRules({
81583
- allowSsh: compute.allowSsh !== false,
81584
- sitePorts
81585
- })
81586
- });
81845
+ const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
81846
+ allowSsh: compute.allowSsh !== false,
81847
+ sitePorts
81848
+ }));
81587
81849
  const sshKeyId = await this.ensureSshKey(slug, environment, labels);
81588
81850
  const { server, action } = await this.client.createServer({
81589
81851
  name: serverName,
@@ -81608,6 +81870,11 @@ class HetznerDriver {
81608
81870
  sshUser: this.sshUser
81609
81871
  };
81610
81872
  await writeDriverState(stackName, state);
81873
+ const ip = running.public_net.ipv4?.ip;
81874
+ if (ip && this.waitForBoot) {
81875
+ await this.waitForSshReady(ip);
81876
+ await this.waitForCloudInit(ip);
81877
+ }
81611
81878
  return this.outputsFromState(state, running);
81612
81879
  }
81613
81880
  async getComputeOutputs(options) {
@@ -81714,6 +81981,79 @@ class HetznerDriver {
81714
81981
  });
81715
81982
  return created.id;
81716
81983
  }
81984
+ async tryGetServer(id) {
81985
+ try {
81986
+ return await this.client.getServer(id);
81987
+ } catch {
81988
+ return null;
81989
+ }
81990
+ }
81991
+ async findExistingServer(slug, environment, serverName) {
81992
+ const servers = await this.client.listServers();
81993
+ return servers.find((server) => matchesTsCloudLabels(server.labels, slug, environment, "app") || server.name === serverName);
81994
+ }
81995
+ async ensureFirewall(name, labels, rules) {
81996
+ const existing = await this.client.listFirewalls();
81997
+ const match = existing.find((fw) => fw.name === name);
81998
+ if (match) {
81999
+ await this.client.setFirewallRules(match.id, rules);
82000
+ return { firewall: match };
82001
+ }
82002
+ const { firewall } = await this.client.createFirewall({ name, labels, rules });
82003
+ return { firewall };
82004
+ }
82005
+ collectUpstreamPorts(sites, proxy) {
82006
+ const ports = new Set;
82007
+ for (const app of proxy?.apps ?? []) {
82008
+ if (typeof app.port === "number")
82009
+ ports.add(app.port);
82010
+ }
82011
+ for (const site of Object.values(sites)) {
82012
+ if (typeof site.port === "number")
82013
+ ports.add(site.port);
82014
+ }
82015
+ return [...ports].filter((port) => ![80, 443].includes(port));
82016
+ }
82017
+ async sleep(ms) {
82018
+ await new Promise((resolve13) => setTimeout(resolve13, ms));
82019
+ }
82020
+ async waitForSshReady(host) {
82021
+ const { sshIntervalMs, sshTimeoutMs } = this.bootWait;
82022
+ const start = Date.now();
82023
+ let lastErr;
82024
+ while (Date.now() - start < sshTimeoutMs) {
82025
+ try {
82026
+ execSync(`ssh ${this.sshBaseArgs(host, ["-o", "ConnectTimeout=5"]).map((a) => `"${a.replace(/"/g, "\\\"")}"`).join(" ")} true`, {
82027
+ stdio: "pipe",
82028
+ maxBuffer: SSH_MAX_BUFFER
82029
+ });
82030
+ return;
82031
+ } catch (err) {
82032
+ lastErr = err;
82033
+ await this.sleep(sshIntervalMs);
82034
+ }
82035
+ }
82036
+ throw new Error(`Timed out waiting for SSH on ${host} after ${sshTimeoutMs}ms: ${lastErr?.message ?? "unknown error"}`);
82037
+ }
82038
+ async waitForCloudInit(host) {
82039
+ const { cloudInitIntervalMs, cloudInitTimeoutMs } = this.bootWait;
82040
+ const start = Date.now();
82041
+ while (Date.now() - start < cloudInitTimeoutMs) {
82042
+ try {
82043
+ const out = this.sshExec(host, "cloud-init status --long 2>/dev/null || cloud-init status 2>/dev/null || echo status:\\ done");
82044
+ if (/status:\s*done/.test(out))
82045
+ return;
82046
+ if (/status:\s*error/.test(out))
82047
+ throw new Error(`cloud-init reported an error on ${host}:
82048
+ ${out}`);
82049
+ } catch (err) {
82050
+ if (err instanceof Error && /cloud-init reported an error/.test(err.message))
82051
+ throw err;
82052
+ }
82053
+ await this.sleep(cloudInitIntervalMs);
82054
+ }
82055
+ throw new Error(`Timed out waiting for cloud-init to finish on ${host} after ${cloudInitTimeoutMs}ms`);
82056
+ }
81717
82057
  outputsFromState(state, server) {
81718
82058
  return {
81719
82059
  deployStoragePath: state.deployStoragePath || "/var/ts-cloud/staging",
@@ -81722,7 +82062,7 @@ class HetznerDriver {
81722
82062
  sshUser: state.sshUser || this.sshUser
81723
82063
  };
81724
82064
  }
81725
- sshBaseArgs(host) {
82065
+ sshBaseArgs(host, extra = []) {
81726
82066
  return [
81727
82067
  "-i",
81728
82068
  this.sshPrivateKeyPath,
@@ -81730,6 +82070,7 @@ class HetznerDriver {
81730
82070
  "StrictHostKeyChecking=accept-new",
81731
82071
  "-o",
81732
82072
  "BatchMode=yes",
82073
+ ...extra,
81733
82074
  `${this.sshUser}@${host}`
81734
82075
  ];
81735
82076
  }
@@ -81843,6 +82184,19 @@ function buildSiteDeployScript(options) {
81843
82184
  `systemctl is-active ${serviceName}`
81844
82185
  ];
81845
82186
  }
82187
+ function buildStaticSiteDeployScript(options) {
82188
+ const { siteName, artifactFetch, preStartCommands = [] } = options;
82189
+ const appDir = options.appDir ?? `/var/www/${siteName}`;
82190
+ const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
82191
+ return [
82192
+ "set -euo pipefail",
82193
+ ...artifactFetch,
82194
+ `mkdir -p ${appDir}`,
82195
+ `find ${appDir} -mindepth 1 -maxdepth 1 -exec rm -rf {} +`,
82196
+ `tar xzf /tmp/${siteName}-release.tar.gz -C ${appDir}`,
82197
+ ...preStart
82198
+ ];
82199
+ }
81846
82200
  function buildAwsArtifactFetch(bucket, key, region, siteName) {
81847
82201
  return [
81848
82202
  `aws s3 cp "s3://${bucket}/${key}" /tmp/${siteName}-release.tar.gz --region ${region}`
@@ -81892,7 +82246,11 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
81892
82246
  targets
81893
82247
  });
81894
82248
  const artifactFetch = driver.name === "aws" ? buildAwsArtifactFetch(outputs.deployBucketName, remoteKey, config6.project.region || "us-east-1", siteName) : buildLocalArtifactFetch(uploadResult.artifactRef, siteName);
81895
- const remoteScript = buildSiteDeployScript({
82249
+ const remoteScript = resolveSiteKind(site) === "server-static" ? buildStaticSiteDeployScript({
82250
+ siteName,
82251
+ artifactFetch,
82252
+ preStartCommands: site.preStart
82253
+ }) : buildSiteDeployScript({
81896
82254
  siteName,
81897
82255
  slug,
81898
82256
  artifactFetch,
@@ -81931,8 +82289,11 @@ async function deployAllComputeSites(options) {
81931
82289
  const slug = config6.project.slug;
81932
82290
  const sites = config6.sites || {};
81933
82291
  const deployable = Object.entries(sites).filter(([name, site]) => {
81934
- if (!site?.start) {
81935
- logger4.warn(`Site '${name}' has no \`start\` command — skipping (compute mode requires every site to declare how to run).`);
82292
+ if (!site)
82293
+ return false;
82294
+ const kind = resolveSiteKind(site);
82295
+ if (kind === "bucket") {
82296
+ logger4.warn(`Site '${name}' targets a bucket — skipping (handled by the static-site path, not compute).`);
81936
82297
  return false;
81937
82298
  }
81938
82299
  return true;
@@ -81984,6 +82345,7 @@ export {
81984
82345
  validateTemplateSize,
81985
82346
  validateTemplate,
81986
82347
  validateResourceLimits,
82348
+ validateDeploymentConfig,
81987
82349
  validateCredentials,
81988
82350
  validateConfiguration,
81989
82351
  validateCommand,
@@ -81999,6 +82361,7 @@ export {
81999
82361
  suggestFlags,
82000
82362
  suggestCommand,
82001
82363
  storageAdvancedManager,
82364
+ staticSiteServerRoot,
82002
82365
  staticSiteManager,
82003
82366
  stackDependencyManager,
82004
82367
  signRequestAsync,
@@ -82019,6 +82382,8 @@ export {
82019
82382
  resolveStorageBucketName,
82020
82383
  resolveSiteStackName,
82021
82384
  resolveSiteResourceName,
82385
+ resolveSiteKind,
82386
+ resolveSiteDeployTarget,
82022
82387
  resolveSiteBucketName,
82023
82388
  resolveRegion,
82024
82389
  resolveProjectStackName,
@@ -82028,11 +82393,13 @@ export {
82028
82393
  resolveDeployBucketName,
82029
82394
  resolveCredentials,
82030
82395
  resolveCloudProvider,
82396
+ resolveCaddyfile,
82031
82397
  requiresReplacement,
82032
82398
  replicaManager,
82033
82399
  regionPairManager,
82034
82400
  quickHash,
82035
82401
  queueManagementManager,
82402
+ proxyConfigFromSites,
82036
82403
  providerEndpoint,
82037
82404
  progressiveDeploymentManager,
82038
82405
  processInChunks,
@@ -82065,6 +82432,7 @@ export {
82065
82432
  lambdaConcurrencyManager,
82066
82433
  isWebCryptoAvailable,
82067
82434
  isValidRegion,
82435
+ isOnDemandDomain,
82068
82436
  isNodeCryptoAvailable,
82069
82437
  isLocalDevelopment,
82070
82438
  isLikelyTypo,
@@ -82213,9 +82581,11 @@ export {
82213
82581
  certificateManager,
82214
82582
  categorizeChanges,
82215
82583
  canaryManager,
82584
+ buildStaticSiteDeployScript,
82216
82585
  buildSiteDeployScript,
82217
82586
  buildOptimizationManager,
82218
82587
  buildCloudFormationTemplate,
82588
+ buildCaddyfileFromProxy,
82219
82589
  buildCaddyfile,
82220
82590
  bounceComplaintHandler,
82221
82591
  blueGreenManager,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/ts-cloud",
3
3
  "type": "module",
4
- "version": "0.2.21",
4
+ "version": "0.2.22",
5
5
  "description": "A lightweight, performant infrastructure-as-code library and CLI for deploying both server-based (EC2) and serverless applications.",
6
6
  "author": "Chris Breuer <chris@stacksjs.com>",
7
7
  "license": "MIT",
@@ -89,8 +89,8 @@
89
89
  "test": "bun test"
90
90
  },
91
91
  "dependencies": {
92
- "@ts-cloud/aws-types": "0.2.21",
93
- "@ts-cloud/core": "0.2.21",
92
+ "@ts-cloud/aws-types": "0.2.22",
93
+ "@ts-cloud/core": "0.2.22",
94
94
  "@stacksjs/ts-xml": "^0.1.0"
95
95
  },
96
96
  "devDependencies": {