@stacksjs/ts-cloud 0.7.119 → 0.7.121

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 (75) hide show
  1. package/dist/bin/cli.js +567 -566
  2. package/dist/bin/dashboard-server.js +426 -425
  3. package/dist/{chunk-8xezrv4z.js → chunk-0cx84wm2.js} +5 -5
  4. package/dist/{chunk-19bs955w.js → chunk-8ynjhsh3.js} +1 -1
  5. package/dist/{chunk-wmbep3b5.js → chunk-b801ga2t.js} +1 -1
  6. package/dist/{chunk-53jatzaw.js → chunk-w2zd56qm.js} +2 -2
  7. package/dist/{chunk-t2s3gdq6.js → chunk-xes5vst2.js} +70 -22
  8. package/dist/{chunk-nm1d3a75.js → chunk-zcjc5y9f.js} +171 -29
  9. package/dist/deploy/index.js +5 -5
  10. package/dist/deploy/release-content.d.ts +37 -0
  11. package/dist/deploy/release-content.test.d.ts +1 -0
  12. package/dist/drivers/aws/driver.d.ts +19 -1
  13. package/dist/drivers/aws/provision.d.ts +46 -0
  14. package/dist/drivers/hetzner/client.d.ts +5 -0
  15. package/dist/drivers/hetzner/driver.d.ts +6 -2
  16. package/dist/drivers/hetzner/instance-sizes.d.ts +10 -0
  17. package/dist/drivers/index.js +2 -2
  18. package/dist/drivers/shared/deploy-script.d.ts +7 -0
  19. package/dist/drivers/shared/sftp-provision.d.ts +45 -0
  20. package/dist/drivers/shared/sftp-provision.test.d.ts +1 -0
  21. package/dist/generators/infrastructure.d.ts +10 -0
  22. package/dist/index.js +39 -7
  23. package/dist/protection/index.js +1 -1
  24. package/dist/spend/index.js +2 -2
  25. package/dist/ui/access-denied.html +2 -2
  26. package/dist/ui/account/automation.html +4 -4
  27. package/dist/ui/account/security.html +2 -2
  28. package/dist/ui/applications/compose.html +4 -4
  29. package/dist/ui/applications/new.html +2 -2
  30. package/dist/ui/data/backups.html +4 -4
  31. package/dist/ui/data/services.html +4 -4
  32. package/dist/ui/data/volumes.html +3 -3
  33. package/dist/ui/index.html +3 -3
  34. package/dist/ui/infrastructure/topology.html +4 -4
  35. package/dist/ui/operations/alerts.html +4 -4
  36. package/dist/ui/operations/configuration.html +4 -4
  37. package/dist/ui/operations/jobs.html +4 -4
  38. package/dist/ui/operations/maintenance.html +4 -4
  39. package/dist/ui/operations/observability.html +4 -4
  40. package/dist/ui/operations/previews.html +4 -4
  41. package/dist/ui/operations/queue.html +4 -4
  42. package/dist/ui/operations/regions.html +4 -4
  43. package/dist/ui/operations/releases.html +4 -4
  44. package/dist/ui/operations/spend.html +4 -4
  45. package/dist/ui/operations/workloads.html +3 -3
  46. package/dist/ui/security.html +2 -2
  47. package/dist/ui/server/actions.html +4 -4
  48. package/dist/ui/server/activity.html +2 -2
  49. package/dist/ui/server/capacity.html +4 -4
  50. package/dist/ui/server/database.html +3 -3
  51. package/dist/ui/server/deployments.html +4 -4
  52. package/dist/ui/server/diagnostics.html +2 -2
  53. package/dist/ui/server/firewall.html +4 -4
  54. package/dist/ui/server/fleet.html +4 -4
  55. package/dist/ui/server/logs.html +4 -4
  56. package/dist/ui/server/metrics.html +4 -4
  57. package/dist/ui/server/security.html +2 -2
  58. package/dist/ui/server/services.html +2 -2
  59. package/dist/ui/server/sites.html +4 -4
  60. package/dist/ui/server/ssh-keys.html +4 -4
  61. package/dist/ui/server/team.html +4 -4
  62. package/dist/ui/server/terminal.html +2 -2
  63. package/dist/ui/serverless/alarms.html +4 -4
  64. package/dist/ui/serverless/assets.html +2 -2
  65. package/dist/ui/serverless/data.html +4 -4
  66. package/dist/ui/serverless/deployments.html +2 -2
  67. package/dist/ui/serverless/firewall.html +2 -2
  68. package/dist/ui/serverless/functions.html +3 -3
  69. package/dist/ui/serverless/logs.html +4 -4
  70. package/dist/ui/serverless/metrics.html +2 -2
  71. package/dist/ui/serverless/queues.html +4 -4
  72. package/dist/ui/serverless/secrets.html +4 -4
  73. package/dist/ui/serverless/traces.html +4 -4
  74. package/dist/ui/serverless.html +4 -4
  75. package/package.json +3 -3
@@ -9,7 +9,7 @@ import {
9
9
  step,
10
10
  success,
11
11
  warn
12
- } from "./chunk-wmbep3b5.js";
12
+ } from "./chunk-b801ga2t.js";
13
13
  import {
14
14
  AwsBackupClient,
15
15
  CloudWatchLogsClient,
@@ -50,7 +50,7 @@ import {
50
50
  resolveSiteKind,
51
51
  resolveUiSource,
52
52
  siteInstallBase
53
- } from "./chunk-nm1d3a75.js";
53
+ } from "./chunk-zcjc5y9f.js";
54
54
  import {
55
55
  SSMClient
56
56
  } from "./chunk-7m60qnc8.js";
@@ -104,7 +104,7 @@ import {
104
104
  spendOpenApiPaths,
105
105
  telemetryCursor,
106
106
  telemetryEstimatedMonthlyCost
107
- } from "./chunk-19bs955w.js";
107
+ } from "./chunk-8ynjhsh3.js";
108
108
  import {
109
109
  artifactKey,
110
110
  buildCloudFormationTemplate,
@@ -126,7 +126,7 @@ import {
126
126
  resolveStatePath,
127
127
  setStateDir,
128
128
  statePath
129
- } from "./chunk-t2s3gdq6.js";
129
+ } from "./chunk-xes5vst2.js";
130
130
  import {
131
131
  __require
132
132
  } from "./chunk-v0bahtg2.js";
@@ -1729,7 +1729,7 @@ async function deployServerlessApp(config, environment, opts = {}) {
1729
1729
  }
1730
1730
  if (imageMode) {
1731
1731
  step("Building + pushing container image");
1732
- const { buildAndPushServerlessImage } = await import("./chunk-53jatzaw.js");
1732
+ const { buildAndPushServerlessImage } = await import("./chunk-w2zd56qm.js");
1733
1733
  const built = await buildAndPushServerlessImage({
1734
1734
  app,
1735
1735
  projectRoot,
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  resolveStatePath,
6
6
  statePath
7
- } from "./chunk-t2s3gdq6.js";
7
+ } from "./chunk-xes5vst2.js";
8
8
 
9
9
  // src/spend/model.ts
10
10
  var METER_KEYS = [
@@ -8,7 +8,7 @@ import {
8
8
  phpRuntimeLayerAssets,
9
9
  runPhpBuildHooks,
10
10
  sha256
11
- } from "./chunk-t2s3gdq6.js";
11
+ } from "./chunk-xes5vst2.js";
12
12
 
13
13
  // src/deploy/serverless-image.ts
14
14
  import { execFileSync } from "node:child_process";
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  buildAndPushServerlessImage
3
- } from "./chunk-wmbep3b5.js";
3
+ } from "./chunk-b801ga2t.js";
4
4
  import"./chunk-50jpda9q.js";
5
5
  import"./chunk-zqtpg06c.js";
6
- import"./chunk-t2s3gdq6.js";
6
+ import"./chunk-xes5vst2.js";
7
7
  import"./chunk-v0bahtg2.js";
8
8
  export {
9
9
  buildAndPushServerlessImage
@@ -10260,11 +10260,45 @@ function normalizeHomeDirectory(value, username) {
10260
10260
  throw new Error(`sftp: invalid homeDirectory for user ${username}`);
10261
10261
  return path;
10262
10262
  }
10263
+ function resolveStorage(options) {
10264
+ const storage = options.storage;
10265
+ if (storage?.type === "efs") {
10266
+ const fileSystemId = "fileSystemId" in storage ? storage.fileSystemId : undefined;
10267
+ if (!fileSystemId || typeof fileSystemId === "string" && !fileSystemId.trim())
10268
+ throw new Error("sftp: EFS storage requires fileSystemId, or a fileSystem name defined in infrastructure.fileSystem");
10269
+ return { type: "efs", fileSystemId, posixProfile: storage.posixProfile };
10270
+ }
10271
+ const bucket = (storage?.type === "s3" ? storage.bucket ?? options.bucket : options.bucket)?.trim();
10272
+ if (!bucket)
10273
+ throw new Error("sftp: S3 storage requires bucket, or a storageBucket name defined in infrastructure.storage");
10274
+ return { type: "s3", bucket };
10275
+ }
10276
+ function posixProfileFor(username, user, fallback) {
10277
+ const profile = user.posixProfile ?? fallback;
10278
+ if (!profile)
10279
+ throw new Error(`sftp: user ${username} requires a posixProfile (uid/gid) on an EFS-backed server`);
10280
+ for (const id of [profile.uid, profile.gid, ...profile.secondaryGids ?? []]) {
10281
+ if (!Number.isInteger(id) || id < 0 || id > 4294967295)
10282
+ throw new Error(`sftp: user ${username} has an invalid posixProfile id ${id}`);
10283
+ }
10284
+ return {
10285
+ Uid: profile.uid,
10286
+ Gid: profile.gid,
10287
+ ...profile.secondaryGids?.length ? { SecondaryGids: profile.secondaryGids } : {}
10288
+ };
10289
+ }
10290
+ function efsHomeDirectory(fileSystemId, home) {
10291
+ return typeof fileSystemId === "string" ? `/${fileSystemId}/${home}` : { "Fn::Sub": [`/\${FileSystemId}/${home}`, { FileSystemId: fileSystemId }] };
10292
+ }
10293
+ function efsFileSystemArn(fileSystemId) {
10294
+ const template = "arn:aws:elasticfilesystem:${AWS::Region}:${AWS::AccountId}:file-system/${FileSystemId}";
10295
+ return typeof fileSystemId === "string" ? { "Fn::Sub": template.replace("${FileSystemId}", fileSystemId) } : { "Fn::Sub": [template, { FileSystemId: fileSystemId }] };
10296
+ }
10263
10297
 
10264
10298
  class Sftp {
10265
10299
  static create(options) {
10266
- if (!options.bucket.trim())
10267
- throw new Error("sftp: bucket is required");
10300
+ const storage = resolveStorage(options);
10301
+ const domain = storage.type === "efs" ? "EFS" : "S3";
10268
10302
  const endpointType = options.endpointType ?? "PUBLIC";
10269
10303
  if (endpointType === "VPC" && (!options.endpointDetails?.vpcId || !options.endpointDetails.subnetIds.length))
10270
10304
  throw new Error("sftp: VPC endpoints require endpointDetails.vpcId and at least one subnet");
@@ -10310,7 +10344,7 @@ class Sftp {
10310
10344
  resources[serverLogicalId] = {
10311
10345
  Type: "AWS::Transfer::Server",
10312
10346
  Properties: {
10313
- Domain: "S3",
10347
+ Domain: domain,
10314
10348
  EndpointType: endpointType,
10315
10349
  IdentityProviderType: "SERVICE_MANAGED",
10316
10350
  Protocols: ["SFTP"],
@@ -10342,7 +10376,35 @@ class Sftp {
10342
10376
  let roleArn = user.roleArn;
10343
10377
  if (!roleArn) {
10344
10378
  const roleLogicalId = `${prefix}${userPart}Role`;
10345
- const bucketArn = `arn:aws:s3:::${options.bucket}`;
10379
+ let statements;
10380
+ if (storage.type === "efs") {
10381
+ statements = [
10382
+ {
10383
+ Effect: "Allow",
10384
+ Action: [
10385
+ "elasticfilesystem:ClientMount",
10386
+ "elasticfilesystem:ClientWrite",
10387
+ "elasticfilesystem:DescribeMountTargets"
10388
+ ],
10389
+ Resource: efsFileSystemArn(storage.fileSystemId)
10390
+ }
10391
+ ];
10392
+ } else {
10393
+ const bucketArn = `arn:aws:s3:::${storage.bucket}`;
10394
+ statements = [
10395
+ {
10396
+ Effect: "Allow",
10397
+ Action: ["s3:ListBucket", "s3:GetBucketLocation"],
10398
+ Resource: bucketArn,
10399
+ Condition: { StringLike: { "s3:prefix": [home, `${home}/*`] } }
10400
+ },
10401
+ {
10402
+ Effect: "Allow",
10403
+ Action: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:GetObjectVersion"],
10404
+ Resource: `${bucketArn}/${home}/*`
10405
+ }
10406
+ ];
10407
+ }
10346
10408
  resources[roleLogicalId] = {
10347
10409
  Type: "AWS::IAM::Role",
10348
10410
  Properties: {
@@ -10355,22 +10417,7 @@ class Sftp {
10355
10417
  Policies: [
10356
10418
  {
10357
10419
  PolicyName: "SftpHomeDirectory",
10358
- PolicyDocument: {
10359
- Version: "2012-10-17",
10360
- Statement: [
10361
- {
10362
- Effect: "Allow",
10363
- Action: ["s3:ListBucket", "s3:GetBucketLocation"],
10364
- Resource: bucketArn,
10365
- Condition: { StringLike: { "s3:prefix": [home, `${home}/*`] } }
10366
- },
10367
- {
10368
- Effect: "Allow",
10369
- Action: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:GetObjectVersion"],
10370
- Resource: `${bucketArn}/${home}/*`
10371
- }
10372
- ]
10373
- }
10420
+ PolicyDocument: { Version: "2012-10-17", Statement: statements }
10374
10421
  }
10375
10422
  ]
10376
10423
  }
@@ -10385,12 +10432,13 @@ class Sftp {
10385
10432
  UserName: username,
10386
10433
  Role: roleArn,
10387
10434
  HomeDirectoryType: "PATH",
10388
- HomeDirectory: `/${options.bucket}/${home}`,
10435
+ HomeDirectory: storage.type === "efs" ? efsHomeDirectory(storage.fileSystemId, home) : `/${storage.bucket}/${home}`,
10436
+ ...storage.type === "efs" ? { PosixProfile: posixProfileFor(username, user, storage.posixProfile) } : {},
10389
10437
  SshPublicKeys: user.sshPublicKeys
10390
10438
  }
10391
10439
  };
10392
10440
  }
10393
- return { resources, serverLogicalId };
10441
+ return { resources, serverLogicalId, domain };
10394
10442
  }
10395
10443
  }
10396
10444
 
@@ -25,7 +25,7 @@ import {
25
25
  resolveProjectStackName,
26
26
  resolveStatePath,
27
27
  statePath
28
- } from "./chunk-t2s3gdq6.js";
28
+ } from "./chunk-xes5vst2.js";
29
29
  import {
30
30
  __require
31
31
  } from "./chunk-v0bahtg2.js";
@@ -2061,6 +2061,78 @@ function buildProtectionScript(protection = {}, extraPorts = []) {
2061
2061
  return [...buildDdosScript(protection.ddos, extraPorts), ...buildWafScript(protection.waf)];
2062
2062
  }
2063
2063
 
2064
+ // src/drivers/shared/sftp-provision.ts
2065
+ var SFTP_CONFIG_DIR = "/etc/ts-sftp";
2066
+ var SFTP_INSTALL_DIR = "/opt/ts-sftp";
2067
+ var SFTP_BUN_BIN = "/usr/local/bin/ts-sftp-bun";
2068
+ var DEFAULT_SFTP_PORT = 2222;
2069
+ function sftpUnitName(slug) {
2070
+ return `${slug}-sftp`;
2071
+ }
2072
+ function sftpPort(sftp) {
2073
+ return sftp.port ?? DEFAULT_SFTP_PORT;
2074
+ }
2075
+ function sftpRoot(options) {
2076
+ const storage = options.sftp.storage;
2077
+ if (storage?.type === "efs" && storage.path)
2078
+ return storage.path;
2079
+ return `/var/sftp/${options.slug}`;
2080
+ }
2081
+ function quote(value) {
2082
+ return `'${value.split("'").join("'\\''")}'`;
2083
+ }
2084
+ function assertSftpSupported(sftp, provider) {
2085
+ if (provider === "aws")
2086
+ return;
2087
+ const storage = sftp.storage;
2088
+ const wantsBucket = storage?.type === "s3" || !storage && !!sftp.bucket;
2089
+ if (wantsBucket) {
2090
+ throw new Error(`sftp: bucket-backed storage is only available on the aws provider (Transfer Family). ` + `On ${provider}, use storage: { type: 'efs' } to serve a directory on the server.`);
2091
+ }
2092
+ for (const [username, user] of Object.entries(sftp.users)) {
2093
+ if (!user.sshPublicKeys?.length)
2094
+ throw new Error(`sftp: user ${username} needs at least one SSH public key`);
2095
+ }
2096
+ }
2097
+ function buildSftpProvisionScript(options) {
2098
+ const { slug, sftp } = options;
2099
+ const unit = sftpUnitName(slug);
2100
+ const root = sftpRoot(options);
2101
+ const port = sftpPort(sftp);
2102
+ const hostKey = `${SFTP_CONFIG_DIR}/host_key`;
2103
+ const usersDir = `${SFTP_CONFIG_DIR}/users`;
2104
+ const serviceUser = sftp.serviceUser ?? "ts-sftp";
2105
+ const commands = [
2106
+ `# --- ts-sftp (${unit}) ---`,
2107
+ `id -u ${serviceUser} >/dev/null 2>&1 || useradd --system --home-dir ${quote(root)} --shell /usr/sbin/nologin ${serviceUser}`,
2108
+ 'export HOME="${HOME:-/root}"',
2109
+ 'command -v bun >/dev/null 2>&1 || (curl -fsSL https://bun.sh/install | bash && ln -sf "$HOME/.bun/bin/bun" /usr/local/bin/bun)',
2110
+ `mkdir -p ${quote(root)} ${SFTP_CONFIG_DIR} ${usersDir} ${SFTP_INSTALL_DIR}`,
2111
+ `chmod 750 ${SFTP_CONFIG_DIR}`,
2112
+ `cd ${SFTP_INSTALL_DIR} && [ -f package.json ] || printf '{"name":"ts-sftp-host","private":true}' > package.json`,
2113
+ `cd ${SFTP_INSTALL_DIR} && bun add ts-sftp@${sftp.version ?? "latest"} --no-save --production || bun add ts-sftp@${sftp.version ?? "latest"}`,
2114
+ `[ -f ${hostKey} ] || bun ${SFTP_INSTALL_DIR}/node_modules/ts-sftp/dist/bin/cli.js keygen --out ${hostKey} --comment ${quote(`${slug}-sftp`)}`,
2115
+ `chmod 600 ${hostKey}`,
2116
+ 'BUN_REAL=$(readlink -f "$(command -v bun)")',
2117
+ `case "$BUN_REAL" in /root/*) install -m 0755 "$BUN_REAL" ${SFTP_BUN_BIN}; BUN_BIN=${SFTP_BUN_BIN};; *) BUN_BIN="$BUN_REAL";; esac`
2118
+ ];
2119
+ const userFlags = [];
2120
+ for (const [username, user] of Object.entries(sftp.users)) {
2121
+ const home = (user.homeDirectory ?? username).replace(/^\/+|\/+$/g, "");
2122
+ if (!home || home.split("/").includes(".."))
2123
+ throw new Error(`sftp: invalid homeDirectory for user ${username}`);
2124
+ const keysFile = `${usersDir}/${username}.pub`;
2125
+ commands.push(`mkdir -p ${quote(`${root}/${home}`)}`, `printf '%s\\n' ${quote(user.sshPublicKeys.join(`
2126
+ `))} > ${keysFile}`, `chmod 644 ${keysFile}`);
2127
+ userFlags.push(`--user ${username}:${keysFile}`);
2128
+ }
2129
+ commands.push(`chown -R ${serviceUser}:${serviceUser} ${quote(root)} ${SFTP_CONFIG_DIR}`, `cat > /etc/systemd/system/${unit}.service <<TSCLOUD_SFTP_UNIT`, "[Unit]", "Description=ts-sftp file transfer server", "After=network.target", "", "[Service]", `User=${serviceUser}`, `ExecStart=$BUN_BIN ${SFTP_INSTALL_DIR}/node_modules/ts-sftp/dist/bin/cli.js serve --root ${root} --host-key ${hostKey} --port ${port}${sftp.readOnly ? " --read-only" : ""} ${userFlags.join(" ")}`, "Restart=always", "RestartSec=2", "NoNewPrivileges=true", "PrivateTmp=true", "ProtectSystem=strict", `ReadWritePaths=${root}`, "", "[Install]", "WantedBy=multi-user.target", "TSCLOUD_SFTP_UNIT", "systemctl daemon-reload", `systemctl enable ${unit}.service`, `systemctl restart ${unit}.service`);
2130
+ return commands;
2131
+ }
2132
+ function sftpFirewallPorts(sftp) {
2133
+ return sftp ? [sftpPort(sftp)] : [];
2134
+ }
2135
+
2064
2136
  // src/drivers/shared/ufw.ts
2065
2137
  var UFW_BASE_PORTS = [80, 443];
2066
2138
  function buildUfwScript(firewall = {}) {
@@ -2108,6 +2180,11 @@ function buildComputeProvisionScripts(config) {
2108
2180
  if (compute.managedServices) {
2109
2181
  extras.push(...buildServicesProvisionScript(compute.managedServices), ...buildDatabaseSetupScript(appDatabase, compute.managedServices));
2110
2182
  }
2183
+ const sftp = config.infrastructure?.sftp;
2184
+ if (sftp) {
2185
+ assertSftpSupported(sftp, resolveCloudProvider(config));
2186
+ extras.push(...buildSftpProvisionScript({ slug: config.project.slug, sftp }));
2187
+ }
2111
2188
  extras.push(...buildUfwScript(compute.firewall ?? (phpBox ? { enabled: true } : { enabled: false })));
2112
2189
  extras.push(...buildProtectionScript({ ddos: compute.ddos, waf: compute.waf }, compute.firewall?.allowedPorts ?? []));
2113
2190
  extras.push(...buildAutoUpdatesScript(compute.autoUpdates ?? phpBox));
@@ -2340,6 +2417,36 @@ function encodeUserData(userData) {
2340
2417
  function resolveAwsImageId(config) {
2341
2418
  return config.infrastructure?.compute?.image ?? null;
2342
2419
  }
2420
+ function awsSecurityGroupName(slug, environment) {
2421
+ return `${slug}-${environment}-app-sg`;
2422
+ }
2423
+ function awsResourceTags(slug, environment, role = "app") {
2424
+ return [
2425
+ { Key: "Project", Value: slug },
2426
+ { Key: "Environment", Value: environment },
2427
+ { Key: "Role", Value: role },
2428
+ { Key: "ManagedBy", Value: "ts-cloud" }
2429
+ ];
2430
+ }
2431
+ function matchesAwsProjectTags(tags, slug, environment) {
2432
+ if (!tags?.length)
2433
+ return false;
2434
+ const value = (key) => tags.find((tag) => tag.Key === key)?.Value;
2435
+ return value("Project") === slug && value("Environment") === environment;
2436
+ }
2437
+ function selectProjectSecurityGroup(groups, options) {
2438
+ const name = awsSecurityGroupName(options.slug, options.environment);
2439
+ return groups.find((group) => {
2440
+ if (group.GroupName !== name)
2441
+ return false;
2442
+ if (options.vpcId && group.VpcId && group.VpcId !== options.vpcId)
2443
+ return false;
2444
+ const tagged = !!group.Tags?.some((tag) => tag.Key === "Project");
2445
+ if (tagged)
2446
+ return matchesAwsProjectTags(group.Tags, options.slug, options.environment);
2447
+ return !!options.vpcId && group.VpcId === options.vpcId;
2448
+ });
2449
+ }
2343
2450
 
2344
2451
  // src/drivers/aws/driver.ts
2345
2452
  function readPinnedInstanceId(stackName) {
@@ -2356,8 +2463,12 @@ class AwsDriver {
2356
2463
  name = "aws";
2357
2464
  usesCloudFormation = true;
2358
2465
  region;
2466
+ ec2Client;
2467
+ cfnClient;
2359
2468
  constructor(options = {}) {
2360
2469
  this.region = options.region || "us-east-1";
2470
+ this.ec2Client = options.ec2Client ?? ((region) => new EC2Client(region));
2471
+ this.cfnClient = options.cfnClient ?? ((region) => new CloudFormationClient(region));
2361
2472
  }
2362
2473
  resolveRegion(config) {
2363
2474
  return config.project.region || this.region;
@@ -2369,8 +2480,8 @@ class AwsDriver {
2369
2480
  const compute = config.infrastructure?.compute;
2370
2481
  if (!compute)
2371
2482
  throw new Error("infrastructure.compute is required to provision AWS compute");
2372
- const ec2 = new EC2Client(region);
2373
- const existing = await this.findComputeTargets({ slug, environment, role: "app" });
2483
+ const ec2 = this.ec2Client(region);
2484
+ const existing = await this.findComputeTargets({ slug, environment, role: "app", region });
2374
2485
  if (existing.length > 0) {
2375
2486
  const first = existing[0];
2376
2487
  return {
@@ -2398,7 +2509,7 @@ class AwsDriver {
2398
2509
  const subnetId = subnet?.SubnetId;
2399
2510
  if (!subnet?.MapPublicIpOnLaunch)
2400
2511
  console.warn("ts-cloud: no public subnet found; the instance may not get a public IP (deploys/SSL need one).");
2401
- const sgName = `${slug}-${environment}-app-sg`;
2512
+ const sgName = awsSecurityGroupName(slug, environment);
2402
2513
  const found = await ec2.describeSecurityGroups({
2403
2514
  Filters: [
2404
2515
  { Name: "group-name", Values: [sgName] },
@@ -2410,7 +2521,8 @@ class AwsDriver {
2410
2521
  const created = await ec2.createSecurityGroup({
2411
2522
  GroupName: sgName,
2412
2523
  Description: `ts-cloud ${slug}/${environment} app`,
2413
- VpcId: vpc.VpcId
2524
+ VpcId: vpc.VpcId,
2525
+ TagSpecifications: [{ ResourceType: "security-group", Tags: awsResourceTags(slug, environment) }]
2414
2526
  });
2415
2527
  groupId = created.GroupId;
2416
2528
  }
@@ -2441,12 +2553,7 @@ class AwsDriver {
2441
2553
  TagSpecifications: [
2442
2554
  {
2443
2555
  ResourceType: "instance",
2444
- Tags: [
2445
- { Key: "Name", Value: `${slug}-${environment}-app` },
2446
- { Key: "Project", Value: slug },
2447
- { Key: "Environment", Value: environment },
2448
- { Key: "Role", Value: "app" }
2449
- ]
2556
+ Tags: [{ Key: "Name", Value: `${slug}-${environment}-app` }, ...awsResourceTags(slug, environment)]
2450
2557
  }
2451
2558
  ]
2452
2559
  });
@@ -2466,11 +2573,17 @@ class AwsDriver {
2466
2573
  }
2467
2574
  async destroyCompute(options) {
2468
2575
  const { config, environment } = options;
2576
+ const slug = config.project.slug;
2469
2577
  const region = this.resolveRegion(config);
2470
- const ec2 = new EC2Client(region);
2578
+ const ec2 = this.ec2Client(region);
2471
2579
  const destroyed = [];
2472
- const targets = await this.findComputeTargets({ slug: config.project.slug, environment, role: "app" });
2580
+ const targets = await this.findComputeTargets({ slug, environment, role: "app", region });
2473
2581
  const ids = targets.map((t) => t.id);
2582
+ let vpcId;
2583
+ if (ids.length > 0) {
2584
+ const described = await ec2.describeInstances({ InstanceIds: ids }).catch(() => ({ Reservations: [] }));
2585
+ vpcId = described.Reservations?.flatMap((reservation) => reservation.Instances ?? []).find((i) => i.VpcId)?.VpcId;
2586
+ }
2474
2587
  if (ids.length > 0) {
2475
2588
  await ec2.terminateInstances(ids);
2476
2589
  destroyed.push(...ids.map((id) => `instance ${id}`));
@@ -2478,13 +2591,16 @@ class AwsDriver {
2478
2591
  return;
2479
2592
  })));
2480
2593
  }
2481
- const sgName = `${config.project.slug}-${environment}-app-sg`;
2482
- const found = await ec2.describeSecurityGroups({ Filters: [{ Name: "group-name", Values: [sgName] }] }).catch(() => ({ SecurityGroups: [] }));
2483
- const groupId = found.SecurityGroups?.[0]?.GroupId;
2484
- if (groupId) {
2594
+ const sgName = awsSecurityGroupName(slug, environment);
2595
+ const filters = [{ Name: "group-name", Values: [sgName] }];
2596
+ if (vpcId)
2597
+ filters.push({ Name: "vpc-id", Values: [vpcId] });
2598
+ const found = await ec2.describeSecurityGroups({ Filters: filters }).catch(() => ({ SecurityGroups: [] }));
2599
+ const group = selectProjectSecurityGroup(found.SecurityGroups ?? [], { slug, environment, vpcId });
2600
+ if (group?.GroupId) {
2485
2601
  for (let i = 0;i < 10; i++) {
2486
2602
  try {
2487
- await ec2.deleteSecurityGroup(groupId);
2603
+ await ec2.deleteSecurityGroup(group.GroupId);
2488
2604
  destroyed.push(`security group ${sgName}`);
2489
2605
  break;
2490
2606
  } catch {
@@ -2497,7 +2613,7 @@ class AwsDriver {
2497
2613
  async getComputeOutputs(options) {
2498
2614
  const region = this.resolveRegion(options.config);
2499
2615
  const stackName = resolveProjectStackName(options.config, options.environment);
2500
- const cfn = new CloudFormationClient(region);
2616
+ const cfn = this.cfnClient(region);
2501
2617
  try {
2502
2618
  const outputs = await cfn.getStackOutputs(stackName);
2503
2619
  return {
@@ -2512,7 +2628,8 @@ class AwsDriver {
2512
2628
  const targets = await this.findComputeTargets({
2513
2629
  slug: options.config.project.slug,
2514
2630
  environment: options.environment,
2515
- role: "app"
2631
+ role: "app",
2632
+ region
2516
2633
  });
2517
2634
  const first = targets[0];
2518
2635
  return {
@@ -2544,8 +2661,8 @@ class AwsDriver {
2544
2661
  return { artifactRef: `s3://${bucket}/${options.remoteKey}` };
2545
2662
  }
2546
2663
  async findComputeTargets(options) {
2547
- const region = this.region;
2548
- const ec2 = new EC2Client(region);
2664
+ const region = options.region ?? this.region;
2665
+ const ec2 = this.ec2Client(region);
2549
2666
  const filters = [
2550
2667
  { Name: "tag:Project", Values: [options.slug] },
2551
2668
  { Name: "tag:Environment", Values: [options.environment] },
@@ -2560,7 +2677,7 @@ class AwsDriver {
2560
2677
  let pinnedId = readPinnedInstanceId(stackName);
2561
2678
  if (!pinnedId) {
2562
2679
  try {
2563
- pinnedId = (await new CloudFormationClient(region).getStackOutputs(stackName)).appInstanceId ?? null;
2680
+ pinnedId = (await this.cfnClient(region).getStackOutputs(stackName)).appInstanceId ?? null;
2564
2681
  } catch {
2565
2682
  pinnedId = null;
2566
2683
  }
@@ -2988,6 +3105,9 @@ class HetznerClient {
2988
3105
  });
2989
3106
  return data.ssh_key;
2990
3107
  }
3108
+ async deleteSshKey(id) {
3109
+ await this.request("DELETE", `/ssh_keys/${id}`);
3110
+ }
2991
3111
  async getAction(actionId) {
2992
3112
  const data = await this.request("GET", `/actions/${actionId}`);
2993
3113
  return data.action;
@@ -3180,9 +3300,12 @@ function tsCloudLabels(slug, environment, role = "app") {
3180
3300
  };
3181
3301
  }
3182
3302
  function matchesTsCloudLabels(labels, slug, environment, role = "app") {
3303
+ return matchesTsCloudProject(labels, slug, environment) && labels[`${TS_CLOUD_LABEL_PREFIX}/role`] === role;
3304
+ }
3305
+ function matchesTsCloudProject(labels, slug, environment) {
3183
3306
  if (!labels)
3184
3307
  return false;
3185
- return labels[`${TS_CLOUD_LABEL_PREFIX}/project`] === slug && labels[`${TS_CLOUD_LABEL_PREFIX}/environment`] === environment && labels[`${TS_CLOUD_LABEL_PREFIX}/role`] === role;
3308
+ return labels[`${TS_CLOUD_LABEL_PREFIX}/project`] === slug && labels[`${TS_CLOUD_LABEL_PREFIX}/environment`] === environment;
3186
3309
  }
3187
3310
 
3188
3311
  // src/drivers/hetzner/state.ts
@@ -3297,7 +3420,10 @@ class HetznerDriver {
3297
3420
  const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
3298
3421
  allowSsh: compute.allowSsh !== false,
3299
3422
  sitePorts,
3300
- allowedPorts: compute.firewall?.allowedPorts
3423
+ allowedPorts: [
3424
+ ...compute.firewall?.allowedPorts ?? [],
3425
+ ...sftpFirewallPorts(config.infrastructure?.sftp)
3426
+ ]
3301
3427
  }));
3302
3428
  const sshKeyId = await this.ensureSshKey(slug, environment, labels);
3303
3429
  const existing = await readDriverState(stackName);
@@ -3800,6 +3926,14 @@ class HetznerDriver {
3800
3926
  }
3801
3927
  }
3802
3928
  }
3929
+ const sshKeys = await this.client.listSshKeys().catch(() => []);
3930
+ const ourKeys = sshKeys.filter((key) => matchesTsCloudProject(key.labels, slug, environment));
3931
+ for (const key of ourKeys) {
3932
+ try {
3933
+ await this.client.deleteSshKey(key.id);
3934
+ destroyed.push(`ssh key ${key.name}`);
3935
+ } catch {}
3936
+ }
3803
3937
  await writeDriverState(stackName, { provider: "hetzner", stackName }).catch(() => {});
3804
3938
  return { destroyed };
3805
3939
  }
@@ -4089,7 +4223,7 @@ class HetznerDriver {
4089
4223
  let lastErr;
4090
4224
  while (Date.now() - start < sshTimeoutMs) {
4091
4225
  try {
4092
- execSync(`ssh ${this.sshBaseArgs(host, ["-o", "ConnectTimeout=5"]).map((a) => `'${a.replace(/'/g, "\\\"")}"`).join(" ")} true`, {
4226
+ execSync(`ssh ${this.sshBaseArgs(host, ["-o", "ConnectTimeout=5"]).map(shellQuote).join(" ")} true`, {
4093
4227
  stdio: "pipe",
4094
4228
  maxBuffer: SSH_MAX_BUFFER
4095
4229
  });
@@ -5518,7 +5652,9 @@ function buildSiteDeployScript(options) {
5518
5652
  keepReleases = DEFAULT_KEEP_RELEASES,
5519
5653
  preStartCommands = [],
5520
5654
  healthCheckPath,
5521
- healthGateSeconds = 5
5655
+ healthGateSeconds = 5,
5656
+ memoryHigh = "2G",
5657
+ memoryMax
5522
5658
  } = options;
5523
5659
  const zeroDowntime = options.zeroDowntime ?? port != null;
5524
5660
  const base = options.appDir ?? `/var/www/${siteName}`;
@@ -5543,7 +5679,8 @@ function buildSiteDeployScript(options) {
5543
5679
  "TS_CLOUD_ENV_EOF",
5544
5680
  `chmod 600 ${paths.shared}/.env`,
5545
5681
  ...buildPromoteStagedRelease(paths),
5546
- `for TS_CLOUD_ENV in ${paths.shared}/.env ${paths.release}/.env ${paths.release}/.env.*; do [ -f "$TS_CLOUD_ENV" ] && sed -i -E '/^[[:space:]]*(PORT|PORT_BACKEND|PORT_ADMIN|PORT_FRONTEND)[[:space:]]*=/d' "$TS_CLOUD_ENV" 2>/dev/null || true; done`,
5682
+ `find ${paths.release} -maxdepth 1 \\( -type f -o -type l \\) -name '.env*' ! -name '.env.example' ! -name '*.example' -delete`,
5683
+ `sed -i -E '/^[[:space:]]*(PORT|PORT_BACKEND|PORT_ADMIN|PORT_FRONTEND)[[:space:]]*=/d' ${paths.shared}/.env 2>/dev/null || true`,
5547
5684
  ...buildLinkSharedPaths(paths, sharedPaths),
5548
5685
  ...preStart
5549
5686
  ];
@@ -5564,6 +5701,9 @@ function buildSiteDeployScript(options) {
5564
5701
  `ExecStart=${execStart}`,
5565
5702
  "Restart=always",
5566
5703
  "RestartSec=5",
5704
+ "MemoryAccounting=true",
5705
+ ...memoryHigh ? [`MemoryHigh=${memoryHigh}`] : [],
5706
+ ...memoryMax ? [`MemoryMax=${memoryMax}`] : [],
5567
5707
  `EnvironmentFile=${paths.releases}/%i/.env`,
5568
5708
  `Environment=PORT=${port}`,
5569
5709
  "",
@@ -6546,7 +6686,9 @@ async function deploySiteRelease(driver, options, logger = noopLogger2) {
6546
6686
  preStartCommands: site.preStart,
6547
6687
  sharedPaths: site.sharedPaths,
6548
6688
  zeroDowntime: site.zeroDowntime !== false,
6549
- healthCheckPath: site.healthCheck?.path
6689
+ healthCheckPath: site.healthCheck?.path,
6690
+ memoryHigh: site.memoryHigh,
6691
+ memoryMax: site.memoryMax
6550
6692
  });
6551
6693
  const wantsNginxStatic = kind === "server-static" && !usesRpxProxy(compute) && !!site.domain;
6552
6694
  const staticVhost = wantsNginxStatic ? buildNginxVhostScript({
@@ -36,7 +36,7 @@ import {
36
36
  synchronizeDashboardUsers,
37
37
  trackDashboardOperation,
38
38
  verifyStaticApiOrigin
39
- } from "../chunk-8xezrv4z.js";
39
+ } from "../chunk-0cx84wm2.js";
40
40
  import {
41
41
  deleteStaticSite,
42
42
  deployStaticSite,
@@ -50,7 +50,7 @@ import {
50
50
  } from "../chunk-v0vpamnf.js";
51
51
  import {
52
52
  buildAndPushServerlessImage
53
- } from "../chunk-wmbep3b5.js";
53
+ } from "../chunk-b801ga2t.js";
54
54
  import"../chunk-6a1t7fnq.js";
55
55
  import"../chunk-nsgqsmrh.js";
56
56
  import"../chunk-01d86gt1.js";
@@ -83,15 +83,15 @@ import {
83
83
  siteInstallBase,
84
84
  validateDeploymentConfig,
85
85
  verifyAddressRecord
86
- } from "../chunk-nm1d3a75.js";
86
+ } from "../chunk-zcjc5y9f.js";
87
87
  import"../chunk-7m60qnc8.js";
88
88
  import"../chunk-4cjrg98a.js";
89
89
  import"../chunk-rds0sy87.js";
90
90
  import"../chunk-zqtpg06c.js";
91
- import"../chunk-19bs955w.js";
91
+ import"../chunk-8ynjhsh3.js";
92
92
  import"../chunk-va2yd85b.js";
93
93
  import"../chunk-aa2dkn7b.js";
94
- import"../chunk-t2s3gdq6.js";
94
+ import"../chunk-xes5vst2.js";
95
95
  import"../chunk-v0bahtg2.js";
96
96
  export {
97
97
  verifyStaticApiOrigin,
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pre-flight check on what a site is about to ship.
3
+ *
4
+ * `existsSync(site.root)` was the only gate before this, and a directory
5
+ * existing says nothing about whether it holds a servable site. The failure it
6
+ * missed is specific and expensive: a static-site generator that renders into a
7
+ * SUBDIRECTORY of its output directory (BunPress writes `<outdir>/.bunpress`,
8
+ * and it is far from alone) leaves the configured `root` existing but holding
9
+ * only that one child. The tarball then ships `current/.bunpress/index.html`,
10
+ * the gateway serves `current/`, and every URL 404s while the deploy prints
11
+ * success and exits 0.
12
+ *
13
+ * So the check looks at the content, and when the content says the root is one
14
+ * level too high it says so by name instead of leaving it to be found in
15
+ * production.
16
+ */
17
+ export interface ReleaseContentIssue {
18
+ level: 'error' | 'warning';
19
+ message: string;
20
+ }
21
+ export interface CheckReleaseContentOptions {
22
+ /** The site's resolved `root`, as configured. */
23
+ root: string;
24
+ /** Site key, for messages. */
25
+ siteName: string;
26
+ /**
27
+ * Static sites are served by path from the release root, so a missing
28
+ * top-level `index.html` means the domain root 404s. An app site ships a
29
+ * source tree and has no such expectation.
30
+ */
31
+ expectIndex: boolean;
32
+ }
33
+ /**
34
+ * Inspect what a site is about to package. Returns every issue found; the
35
+ * caller fails the deploy when any of them is an `error`.
36
+ */
37
+ export declare function checkReleaseContent(options: CheckReleaseContentOptions): ReleaseContentIssue[];
@@ -0,0 +1 @@
1
+ export {};