@stacksjs/ts-cloud 0.5.1 → 0.5.3

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.
@@ -51,3 +51,10 @@ export declare function rollbackServerlessApp(config: CloudConfig, environment:
51
51
  export declare function setMaintenance(config: CloudConfig, environment: EnvironmentType, enabled: boolean, bypassSecret?: string): Promise<void>;
52
52
  /** Invoke the CLI function with an arbitrary command (e.g. `cloud command "migrate"`). */
53
53
  export declare function runRemoteCommand(config: CloudConfig, environment: EnvironmentType, command: string): Promise<string>;
54
+ /**
55
+ * Run a SQL statement against a (private, in-VPC) serverless database via the CLI
56
+ * function — no bastion needed. Requires the `tscloud/serverless` PHP bridge
57
+ * (`tscloud:db-query`). The SQL is base64-encoded so it survives the runtime's
58
+ * whitespace argument parsing.
59
+ */
60
+ export declare function runDbQuery(config: CloudConfig, environment: EnvironmentType, sql: string): Promise<string>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Forge-style operational commands for a provisioned compute fleet, run over
3
+ * the active driver (SSH/SSM): roll a site back to a previous release, read a
4
+ * site's deployment history, and run a reusable server recipe across servers.
5
+ *
6
+ * Each finds the project's targets via the driver, builds a small shell script
7
+ * with the shared generators, and runs it on every box — mirroring how
8
+ * {@link import('./compute-deploy').deploySiteRelease} drives a deploy.
9
+ */
10
+ import type { CloudDriver, EnvironmentType, RemoteDeployInstanceResult } from '@ts-cloud/core';
11
+ export interface ComputeOpsLogger {
12
+ info(message: string): void;
13
+ warn(message: string): void;
14
+ error(message: string): void;
15
+ step(message: string): void;
16
+ success(message: string): void;
17
+ }
18
+ export interface ComputeOpsContext {
19
+ driver: CloudDriver;
20
+ slug: string;
21
+ environment: EnvironmentType;
22
+ /** Target role label. @default 'app' */
23
+ role?: string;
24
+ logger?: ComputeOpsLogger;
25
+ }
26
+ export interface ComputeOpsResult {
27
+ success: boolean;
28
+ error?: string;
29
+ perInstance?: RemoteDeployInstanceResult[];
30
+ }
31
+ /**
32
+ * Roll a site back to a previous release (Forge-style). With `to`, points
33
+ * `current` at `releases/<to>`; otherwise the most recent prior release. After
34
+ * flipping the symlink, php-fpm is restarted and queue workers are signalled so
35
+ * they pick up the rolled-back code.
36
+ */
37
+ export declare function rollbackComputeSite(ctx: ComputeOpsContext, options: {
38
+ siteName: string;
39
+ to?: string;
40
+ }): Promise<ComputeOpsResult>;
41
+ /**
42
+ * Read a site's on-box deployment history (the log written by
43
+ * {@link import('./releases').buildDeployHistoryHeader}). Returns the per-server
44
+ * output so the caller can print the most recent deploys.
45
+ */
46
+ export declare function getComputeDeployHistory(ctx: ComputeOpsContext, options: {
47
+ siteName: string;
48
+ limit?: number;
49
+ }): Promise<ComputeOpsResult>;
50
+ /**
51
+ * Run a reusable server recipe (a bash script) across the project's servers as
52
+ * a chosen user (Forge's Recipes). The recipe runs through a login shell so
53
+ * pantry's env is loaded; output is captured per server.
54
+ */
55
+ export declare function runComputeRecipe(ctx: ComputeOpsContext, options: {
56
+ name: string;
57
+ script: string[];
58
+ user?: string;
59
+ }): Promise<ComputeOpsResult>;
@@ -13,8 +13,15 @@
13
13
  * The block listens on :80 only; TLS (the `:443` block + redirect) is layered
14
14
  * on by certbot in the SSL step, so this stays stable across cert renewals.
15
15
  */
16
- import type { SiteConfig } from '@ts-cloud/core';
16
+ import type { SiteConfig, SiteNginxConfig } from '@ts-cloud/core';
17
17
  export type NginxSiteType = NonNullable<SiteConfig['type']>;
18
+ /**
19
+ * Resolve the nginx directive lines for a site's vhost from its
20
+ * {@link SiteNginxConfig} and the server's reusable templates: the referenced
21
+ * template's lines first, then the per-site `serverSnippet`. Unknown template
22
+ * names resolve to nothing. Returns `[]` when there's no customization.
23
+ */
24
+ export declare function resolveNginxSnippet(nginx: SiteNginxConfig | undefined, templates: Record<string, string[]> | undefined): string[];
18
25
  export interface NginxVhostOptions {
19
26
  /** Site key — names the config file (`/etc/nginx/sites-available/<siteName>`). */
20
27
  siteName: string;
@@ -56,6 +63,14 @@ export interface NginxVhostOptions {
56
63
  password: string;
57
64
  realm?: string;
58
65
  };
66
+ /**
67
+ * Custom nginx directive lines injected into the server block (after the
68
+ * managed directives) — a resolved reusable template plus per-site snippet.
69
+ * See {@link import('@ts-cloud/core').SiteNginxConfig}.
70
+ */
71
+ serverSnippet?: string[];
72
+ /** `client_max_body_size` override for this vhost (e.g. `'256M'`). */
73
+ clientMaxBodySize?: string;
59
74
  }
60
75
  /** Path of the htpasswd file for a site. */
61
76
  export declare function htpasswdPath(siteName: string): string;
@@ -16,7 +16,21 @@ export interface PhpProvisionOptions {
16
16
  installNginx?: boolean;
17
17
  /** Install Composer. @default true */
18
18
  installComposer?: boolean;
19
+ /**
20
+ * Apply production OPcache + php.ini tuning (Forge's "Optimize for
21
+ * Production"). @default true
22
+ */
23
+ optimizeForProduction?: boolean;
24
+ /** Extra `php.ini` directives merged on top of (or instead of) the tuning. */
25
+ ini?: Record<string, string>;
19
26
  }
27
+ /**
28
+ * The production OPcache + runtime tuning Forge applies via "Optimize for
29
+ * Production". Timestamp validation is off because every deploy restarts
30
+ * php-fpm (so a fresh release is always recompiled); the larger buffers and
31
+ * realpath cache cut per-request overhead.
32
+ */
33
+ export declare const PRODUCTION_PHP_INI: Readonly<Record<string, string>>;
20
34
  /** PHP-FPM listen address for nginx `fastcgi_pass` (pantry's php-fpm is TCP). */
21
35
  export declare const PHP_FPM_LISTEN = "127.0.0.1:9074";
22
36
  /**
@@ -27,10 +41,20 @@ export declare const PHP_FPM_LISTEN = "127.0.0.1:9074";
27
41
  export declare function phpFpmSocketPath(_version?: string): string;
28
42
  /** Resolve the default PHP version from the requested set. */
29
43
  export declare function resolveDefaultPhpVersion(options?: PhpProvisionOptions): string;
44
+ /** Resolve the effective php.ini directives for the given options. */
45
+ export declare function resolvePhpIni(options?: PhpProvisionOptions): Record<string, string>;
46
+ /**
47
+ * Write a ts-cloud-managed `php.ini` drop-in with the resolved directives and
48
+ * restart php-fpm so it takes effect. PHP's ini layout is discovered at runtime
49
+ * via `php -i` (works regardless of pantry's compiled paths): the additional-ini
50
+ * scan dir is used when present, otherwise a managed marker block is merged into
51
+ * the loaded `php.ini`. Returns `[]` when there's nothing to set.
52
+ */
53
+ export declare function buildPhpTuningScript(options?: PhpProvisionOptions): string[];
30
54
  /**
31
55
  * Build the shell command lines that install PHP-FPM + Composer (+ the nginx
32
- * binary) via pantry and start php-fpm as a system service. Assumes the pantry
33
- * CLI is already bootstrapped (see
56
+ * binary) via pantry, start php-fpm as a system service, and apply production
57
+ * php.ini tuning. Assumes the pantry CLI is already bootstrapped (see
34
58
  * {@link import('./package-manager').buildPantryBootstrapScript}).
35
59
  */
36
60
  export declare function buildPhpProvisionScript(options?: PhpProvisionOptions): string[];
@@ -16,6 +16,14 @@
16
16
  export declare const DEFAULT_SHARED_PATHS: readonly string[];
17
17
  /** Default number of past releases to retain for rollback. */
18
18
  export declare const DEFAULT_KEEP_RELEASES = 4;
19
+ /** Number of per-deploy output logs to keep on the box. */
20
+ export declare const DEFAULT_KEEP_DEPLOY_LOGS = 20;
21
+ /** ts-cloud metadata dir for a site (deploy history + per-deploy logs). */
22
+ export declare function deployMetaDir(base: string): string;
23
+ /** Append-only deploy history log path for a site. */
24
+ export declare function deployHistoryPath(base: string): string;
25
+ /** Per-deploy output log path for a release. */
26
+ export declare function deployLogPath(base: string, releaseId: string): string;
19
27
  export interface ReleasePaths {
20
28
  /** Site base directory (`/var/www/<site>`). */
21
29
  base: string;
@@ -61,3 +69,23 @@ export declare function buildRollbackScript(paths: ReleasePaths, options?: {
61
69
  * at the newest, so it is never pruned.
62
70
  */
63
71
  export declare function buildPruneReleases(paths: ReleasePaths, keep?: number): string[];
72
+ export interface DeployHistoryOptions {
73
+ /** This deploy's release id. */
74
+ releaseId: string;
75
+ /** Commit SHA being deployed (recorded in the history line). */
76
+ commit?: string;
77
+ /** Branch being deployed. */
78
+ branch?: string;
79
+ /** Per-deploy logs to retain. @default {@link DEFAULT_KEEP_DEPLOY_LOGS} */
80
+ keepLogs?: number;
81
+ }
82
+ /**
83
+ * Header lines that record deployment history + capture per-deploy output
84
+ * (Forge's deployment log). Emitted near the top of the deploy script: it tees
85
+ * all stdout/stderr to `<base>/.ts-cloud/deploys/<releaseId>.log` and installs
86
+ * an EXIT trap that appends a `<ts>\t<releaseId>\t<commit>\t<status>` line to
87
+ * `<base>/.ts-cloud/deploy-history.log` — so both successful and failed deploys
88
+ * are recorded (the trap reads `$?`). Requires bash (the deploy script already
89
+ * uses `set -euo pipefail`).
90
+ */
91
+ export declare function buildDeployHistoryHeader(base: string, options: DeployHistoryOptions): string[];
package/dist/index.js CHANGED
@@ -25951,11 +25951,16 @@ function composeServerlessAppTemplate(opts) {
25951
25951
  ...hasQueue ? { TSCLOUD_QUEUE: queueNames[0] } : {},
25952
25952
  ...app.env ?? {}
25953
25953
  });
25954
+ const efsEnabled = Boolean(app.efs);
25955
+ const efsOpts = typeof app.efs === "object" ? app.efs : {};
25956
+ const efsMountPath = efsOpts.mountPath ?? "/mnt/local";
25957
+ const efsProvision = efsEnabled && !efsOpts.accessPointArn;
25958
+ const efsAccessPoint = efsOpts.accessPointArn ?? (efsProvision ? Fn2.getAtt("EfsAccessPoint", "Arn") : undefined);
25954
25959
  const subnets = app.vpc?.subnets ?? [];
25955
25960
  const hasVpc = subnets.length > 0;
25956
- const needsDataVpc = app.cache?.driver === "elasticache" || app.database?.connection === "aurora-serverless" || Boolean(app.rdsProxy);
25961
+ const needsDataVpc = app.cache?.driver === "elasticache" || app.database?.connection === "aurora-serverless" || Boolean(app.rdsProxy) || efsEnabled;
25957
25962
  if (needsDataVpc && !hasVpc) {
25958
- throw new Error("serverless app: elasticache / aurora-serverless / rdsProxy require app.vpc.subnets (private subnets) to be set.");
25963
+ throw new Error("serverless app: elasticache / aurora-serverless / rdsProxy / efs require app.vpc.subnets (private subnets) to be set.");
25959
25964
  }
25960
25965
  const vpcConfig = hasVpc ? {
25961
25966
  VpcConfig: {
@@ -25966,6 +25971,15 @@ function composeServerlessAppTemplate(opts) {
25966
25971
  ]
25967
25972
  }
25968
25973
  } : {};
25974
+ const efsDependsOn = efsProvision ? subnets.map((_, i) => `EfsMountTarget${i}`) : [];
25975
+ const efsConfig = efsEnabled ? { FileSystemConfigs: [{ Arn: efsAccessPoint, LocalMountPath: efsMountPath }] } : {};
25976
+ if (efsEnabled) {
25977
+ inlinePolicies[0].PolicyDocument.Statement.push({
25978
+ Effect: "Allow",
25979
+ Action: ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite", "elasticfilesystem:ClientRootAccess", "elasticfilesystem:DescribeMountTargets"],
25980
+ Resource: "*"
25981
+ });
25982
+ }
25969
25983
  function addFunction(logicalId, name, handler8, mode, memory, timeout, reservedConcurrency, tmp = tmpStorage) {
25970
25984
  resources[`${logicalId}LogGroup`] = {
25971
25985
  Type: "AWS::Logs::LogGroup",
@@ -25983,7 +25997,7 @@ function composeServerlessAppTemplate(opts) {
25983
25997
  };
25984
25998
  resources[logicalId] = {
25985
25999
  Type: "AWS::Lambda::Function",
25986
- DependsOn: [`${logicalId}LogGroup`],
26000
+ DependsOn: [`${logicalId}LogGroup`, ...efsDependsOn],
25987
26001
  Properties: {
25988
26002
  FunctionName: name,
25989
26003
  Architectures: [architecture],
@@ -25994,7 +26008,8 @@ function composeServerlessAppTemplate(opts) {
25994
26008
  EphemeralStorage: { Size: tmp },
25995
26009
  ...codeProps,
25996
26010
  ...reservedConcurrency !== undefined ? { ReservedConcurrentExecutions: reservedConcurrency } : {},
25997
- ...vpcConfig
26011
+ ...vpcConfig,
26012
+ ...efsConfig
25998
26013
  }
25999
26014
  };
26000
26015
  }
@@ -26243,10 +26258,37 @@ function composeServerlessAppTemplate(opts) {
26243
26258
  DomainName: Fn2.getAtt("AssetsBucket", "RegionalDomainName"),
26244
26259
  OriginAccessControlId: Fn2.ref("AssetsOAC"),
26245
26260
  S3OriginConfig: { OriginAccessIdentity: "" }
26246
- }]
26261
+ }],
26262
+ ...app.assetDomain ? {
26263
+ Aliases: [app.assetDomain],
26264
+ ViewerCertificate: {
26265
+ AcmCertificateArn: app.assetCertificateArn,
26266
+ SslSupportMethod: "sni-only",
26267
+ MinimumProtocolVersion: "TLSv1.2_2021"
26268
+ }
26269
+ } : {}
26247
26270
  }
26248
26271
  }
26249
26272
  };
26273
+ if (app.assetDomain) {
26274
+ if (!app.assetCertificateArn)
26275
+ throw new Error("serverless app: `assetDomain` requires `assetCertificateArn` (a us-east-1 ACM cert — CloudFront only accepts certs from us-east-1).");
26276
+ if (app.hostedZoneId) {
26277
+ resources.AssetsDomainRecord = {
26278
+ Type: "AWS::Route53::RecordSet",
26279
+ Properties: {
26280
+ HostedZoneId: app.hostedZoneId,
26281
+ Name: app.assetDomain,
26282
+ Type: "A",
26283
+ AliasTarget: {
26284
+ DNSName: Fn2.getAtt("AssetsDistribution", "DomainName"),
26285
+ HostedZoneId: "Z2FDTNDATAQYW2"
26286
+ }
26287
+ }
26288
+ };
26289
+ }
26290
+ outputs.AssetDomain = { Description: "Custom asset CDN host", Value: app.assetDomain };
26291
+ }
26250
26292
  resources.AssetsBucketPolicy = {
26251
26293
  Type: "AWS::S3::BucketPolicy",
26252
26294
  Properties: {
@@ -26325,6 +26367,37 @@ function composeServerlessAppTemplate(opts) {
26325
26367
  }
26326
26368
  };
26327
26369
  }
26370
+ if (efsProvision) {
26371
+ resources.EfsFileSystem = {
26372
+ Type: "AWS::EFS::FileSystem",
26373
+ Properties: {
26374
+ Encrypted: true,
26375
+ FileSystemTags: [{ Key: "Name", Value: `${slug}-${environment}-efs` }]
26376
+ }
26377
+ };
26378
+ subnets.forEach((subnetId, i) => {
26379
+ resources[`EfsMountTarget${i}`] = {
26380
+ Type: "AWS::EFS::MountTarget",
26381
+ Properties: {
26382
+ FileSystemId: Fn2.ref("EfsFileSystem"),
26383
+ SubnetId: subnetId,
26384
+ SecurityGroups: [Fn2.getAtt("DataSecurityGroup", "GroupId")]
26385
+ }
26386
+ };
26387
+ });
26388
+ resources.EfsAccessPoint = {
26389
+ Type: "AWS::EFS::AccessPoint",
26390
+ Properties: {
26391
+ FileSystemId: Fn2.ref("EfsFileSystem"),
26392
+ PosixUser: { Uid: 1001, Gid: 1001 },
26393
+ RootDirectory: {
26394
+ Path: "/lambda",
26395
+ CreationInfo: { OwnerUid: 1001, OwnerGid: 1001, Permissions: "0755" }
26396
+ }
26397
+ }
26398
+ };
26399
+ outputs.EfsFileSystemId = { Description: "EFS file system id", Value: Fn2.ref("EfsFileSystem") };
26400
+ }
26328
26401
  if (app.cache?.driver === "elasticache") {
26329
26402
  resources.CacheSubnetGroup = {
26330
26403
  Type: "AWS::ElastiCache::SubnetGroup",
@@ -83132,6 +83205,8 @@ function buildFunctionEnv(app, ctx, environment, mode, secrets, assetUrl, queueN
83132
83205
  TSCLOUD_ENV: environment,
83133
83206
  MAINTENANCE_MODE: "0",
83134
83207
  ...app.octane ? { TSCLOUD_OCTANE: "1" } : {},
83208
+ ...app.serveAssets ? { TSCLOUD_SERVE_ASSETS: "1" } : {},
83209
+ ...app.redirectRobotsTxt === false ? { TSCLOUD_REDIRECT_ROBOTS_TXT: "0" } : {},
83135
83210
  ...laravelDefaults,
83136
83211
  ...infraEnv,
83137
83212
  ...ctx.app.cache?.driver !== "elasticache" ? { TSCLOUD_CACHE_TABLE: `${ctx.slug}-${environment}-cache` } : {},
@@ -83172,13 +83247,15 @@ function* walk3(dir) {
83172
83247
  yield full;
83173
83248
  }
83174
83249
  }
83175
- async function uploadAssets(s32, bucket, dir, prefix) {
83250
+ async function uploadAssets(s32, bucket, dir, prefix, includeDotfiles = false) {
83176
83251
  let count = 0;
83177
83252
  for (const file of walk3(dir)) {
83178
- const key = `${prefix}/${relative6(dir, file).replace(/\\/g, "/")}`;
83253
+ const rel = relative6(dir, file).replace(/\\/g, "/");
83254
+ if (!includeDotfiles && rel.split("/").some((seg) => seg.startsWith(".")))
83255
+ continue;
83179
83256
  await s32.putObject({
83180
83257
  bucket,
83181
- key,
83258
+ key: `${prefix}/${rel}`,
83182
83259
  body: readFileSync13(file),
83183
83260
  contentType: contentType(file),
83184
83261
  cacheControl: "public, max-age=31536000, immutable"
@@ -83249,7 +83326,7 @@ async function deployServerlessApp(config6, environment, opts = {}) {
83249
83326
  step(`Creating artifact bucket ${artifactBucket}`);
83250
83327
  await s32.createBucket(artifactBucket);
83251
83328
  }
83252
- const exists = await s32.headObject(artifactBucket, key).then(() => true).catch(() => false);
83329
+ const exists = await s32.headObject(artifactBucket, key).then((r) => r !== null).catch(() => false);
83253
83330
  if (exists) {
83254
83331
  info("Artifact already uploaded — reusing");
83255
83332
  } else {
@@ -83262,7 +83339,20 @@ async function deployServerlessApp(config6, environment, opts = {}) {
83262
83339
  const composed = composeServerlessAppTemplate({ config: config6, environment, app, handlers, runtimeLayers });
83263
83340
  const templateBody = JSON.stringify(composed.template);
83264
83341
  const capabilities = ["CAPABILITY_NAMED_IAM"];
83265
- const stackExists = await cfn.describeStacks({ stackName }).then((r) => r.Stacks.length > 0).catch(() => false);
83342
+ const status = await cfn.describeStacks({ stackName }).then((r) => r.Stacks[0]?.StackStatus).catch(() => {
83343
+ return;
83344
+ });
83345
+ let stackExists = status !== undefined;
83346
+ if (status && /DELETE_IN_PROGRESS/.test(status)) {
83347
+ step("Waiting for an in-progress stack delete to finish");
83348
+ await cfn.waitForStack(stackName, "stack-delete-complete").catch(() => {});
83349
+ stackExists = false;
83350
+ } else if (status && /(?:ROLLBACK_COMPLETE|REVIEW_IN_PROGRESS|CREATE_FAILED)/.test(status)) {
83351
+ step(`Deleting unusable stack (status ${status}) before recreate`);
83352
+ await cfn.deleteStack(stackName);
83353
+ await cfn.waitForStack(stackName, "stack-delete-complete").catch(() => {});
83354
+ stackExists = false;
83355
+ }
83266
83356
  step(stackExists ? "Updating infrastructure stack" : "Creating infrastructure stack");
83267
83357
  try {
83268
83358
  if (stackExists) {
@@ -83284,10 +83374,10 @@ async function deployServerlessApp(config6, environment, opts = {}) {
83284
83374
  if (app.assets) {
83285
83375
  const assetsDir3 = join16(projectRoot, app.assets);
83286
83376
  if (existsSync17(assetsDir3)) {
83287
- const cdn = outputs.AssetsCdnDomain;
83377
+ const cdn = app.assetDomain || outputs.AssetsCdnDomain;
83288
83378
  assetUrl = cdn ? `https://${cdn}/${artifactSha}` : undefined;
83289
83379
  step(`Syncing assets from ${app.assets}`);
83290
- const n = await uploadAssets(s32, ctx.assetsBucket, assetsDir3, artifactSha);
83380
+ const n = await uploadAssets(s32, ctx.assetsBucket, assetsDir3, artifactSha, app.dotFilesAsAssets);
83291
83381
  info(`Uploaded ${n} asset(s)${assetUrl ? ` → ${assetUrl}` : ""}`);
83292
83382
  } else {
83293
83383
  warn(`Assets directory not found: ${assetsDir3}`);
@@ -83621,6 +83711,19 @@ function buildManagedDbEnv(database) {
83621
83711
  }
83622
83712
 
83623
83713
  // src/drivers/shared/php-provision.ts
83714
+ var PRODUCTION_PHP_INI = {
83715
+ "opcache.enable": "1",
83716
+ "opcache.enable_cli": "1",
83717
+ "opcache.memory_consumption": "256",
83718
+ "opcache.interned_strings_buffer": "16",
83719
+ "opcache.max_accelerated_files": "20000",
83720
+ "opcache.validate_timestamps": "0",
83721
+ "opcache.revalidate_freq": "0",
83722
+ "opcache.save_comments": "1",
83723
+ "opcache.fast_shutdown": "1",
83724
+ realpath_cache_size: "4096K",
83725
+ realpath_cache_ttl: "600"
83726
+ };
83624
83727
  var PHP_FPM_LISTEN = "127.0.0.1:9074";
83625
83728
  function phpFpmSocketPath(_version) {
83626
83729
  return PHP_FPM_LISTEN;
@@ -83629,6 +83732,45 @@ function resolveDefaultPhpVersion(options = {}) {
83629
83732
  const versions = options.versions?.length ? options.versions : ["8.3"];
83630
83733
  return options.default && versions.includes(options.default) ? options.default : versions[0];
83631
83734
  }
83735
+ function resolvePhpIni(options = {}) {
83736
+ const base = options.optimizeForProduction === false ? {} : { ...PRODUCTION_PHP_INI };
83737
+ return { ...base, ...options.ini || {} };
83738
+ }
83739
+ function buildPhpTuningScript(options = {}) {
83740
+ const ini = resolvePhpIni(options);
83741
+ const keys = Object.keys(ini);
83742
+ if (keys.length === 0)
83743
+ return [];
83744
+ const body = keys.map((k) => `${k}=${ini[k]}`);
83745
+ const marker = "ts-cloud-managed";
83746
+ return [
83747
+ pantryEnvActivation(),
83748
+ "TS_CLOUD_SCAN_DIR=$(php -i 2>/dev/null | awk -F' => ' '/^Scan this dir for additional .ini files/{print $2}' | head -1)",
83749
+ `TS_CLOUD_LOADED_INI=$(php -r 'echo php_ini_loaded_file() ?: "";' 2>/dev/null)`,
83750
+ 'if [ -n "$TS_CLOUD_SCAN_DIR" ] && [ "$TS_CLOUD_SCAN_DIR" != "(none)" ]; then',
83751
+ ' mkdir -p "$TS_CLOUD_SCAN_DIR"',
83752
+ ' TS_CLOUD_PHP_INI="$TS_CLOUD_SCAN_DIR/zz-ts-cloud.ini"',
83753
+ ` cat > "$TS_CLOUD_PHP_INI" <<'TS_CLOUD_PHPINI_EOF'`,
83754
+ `; ${marker} — production PHP tuning`,
83755
+ ...body,
83756
+ "TS_CLOUD_PHPINI_EOF",
83757
+ "else",
83758
+ ' if [ -z "$TS_CLOUD_LOADED_INI" ]; then',
83759
+ " TS_CLOUD_INI_DIR=$(php -i 2>/dev/null | awk -F' => ' '/^Configuration File \\(php.ini\\) Path/{print $2}' | head -1)",
83760
+ ' TS_CLOUD_LOADED_INI="$TS_CLOUD_INI_DIR/php.ini"',
83761
+ ' mkdir -p "$TS_CLOUD_INI_DIR"',
83762
+ ' touch "$TS_CLOUD_LOADED_INI"',
83763
+ " fi",
83764
+ ` sed -i '/; ${marker} BEGIN/,/; ${marker} END/d' "$TS_CLOUD_LOADED_INI"`,
83765
+ ` cat >> "$TS_CLOUD_LOADED_INI" <<'TS_CLOUD_PHPINI_EOF'`,
83766
+ `; ${marker} BEGIN`,
83767
+ ...body,
83768
+ `; ${marker} END`,
83769
+ "TS_CLOUD_PHPINI_EOF",
83770
+ "fi",
83771
+ `(cd ${PANTRY_PROJECT_DIR} && pantry restart php-fpm) 2>/dev/null || true`
83772
+ ];
83773
+ }
83632
83774
  function buildPhpProvisionScript(options = {}) {
83633
83775
  const defaultVersion = resolveDefaultPhpVersion(options);
83634
83776
  const installNginx = options.installNginx !== false;
@@ -83640,11 +83782,22 @@ function buildPhpProvisionScript(options = {}) {
83640
83782
  specs.push("nginx.org");
83641
83783
  return [
83642
83784
  ...buildPantryInstallScript(specs),
83643
- ...buildPantryServiceScript(["php-fpm"])
83785
+ ...buildPantryServiceScript(["php-fpm"]),
83786
+ ...buildPhpTuningScript(options)
83644
83787
  ];
83645
83788
  }
83646
83789
 
83647
83790
  // src/drivers/shared/nginx-vhost.ts
83791
+ function resolveNginxSnippet(nginx, templates) {
83792
+ if (!nginx)
83793
+ return [];
83794
+ const out = [];
83795
+ if (nginx.template && templates?.[nginx.template])
83796
+ out.push(...templates[nginx.template]);
83797
+ if (nginx.serverSnippet)
83798
+ out.push(...nginx.serverSnippet);
83799
+ return out;
83800
+ }
83648
83801
  function htpasswdPath(siteName) {
83649
83802
  return `/etc/nginx/.htpasswd-${siteName}`;
83650
83803
  }
@@ -83677,6 +83830,9 @@ function vhostBody(options) {
83677
83830
  " charset utf-8;",
83678
83831
  ""
83679
83832
  ];
83833
+ if (options.clientMaxBodySize) {
83834
+ lines.push(` client_max_body_size ${options.clientMaxBodySize};`, "");
83835
+ }
83680
83836
  if (options.auth) {
83681
83837
  lines.push(` auth_basic "${options.auth.realm || "Restricted"}";`, ` auth_basic_user_file ${htpasswdPath(options.siteName)};`, "");
83682
83838
  }
@@ -83692,6 +83848,11 @@ function vhostBody(options) {
83692
83848
  } else {
83693
83849
  lines.push(" location / {", " try_files $uri $uri/ =404;", " }");
83694
83850
  }
83851
+ if (options.serverSnippet && options.serverSnippet.length > 0) {
83852
+ lines.push("");
83853
+ for (const line of options.serverSnippet)
83854
+ lines.push(line ? ` ${line}` : "");
83855
+ }
83695
83856
  return lines;
83696
83857
  }
83697
83858
  function buildNginxVhost(options) {
@@ -84149,7 +84310,9 @@ function buildComputeProvisionScripts(config6) {
84149
84310
  versions: compute.php?.versions,
84150
84311
  default: compute.php?.default,
84151
84312
  extensions: compute.php?.extensions,
84152
- installNginx: useNginx
84313
+ installNginx: useNginx,
84314
+ optimizeForProduction: compute.php?.optimizeForProduction,
84315
+ ini: compute.php?.ini
84153
84316
  }),
84154
84317
  ...useNginx ? buildNginxServiceScript() : []
84155
84318
  ] : undefined;
@@ -85269,7 +85432,9 @@ class HetznerDriver {
85269
85432
  versions: compute.php?.versions,
85270
85433
  default: compute.php?.default,
85271
85434
  extensions: compute.php?.extensions,
85272
- installNginx: compute.webServer !== "rpx"
85435
+ installNginx: compute.webServer !== "rpx",
85436
+ optimizeForProduction: compute.php?.optimizeForProduction,
85437
+ ini: compute.php?.ini
85273
85438
  });
85274
85439
  const appUserData = wrapCloudInitUserData(buildUbuntuBootstrapScript({ runtime: "php", phpProvision: appPhp, servicesProvision: appProvision, baked }));
85275
85440
  const existingApp = all.filter((s) => matchesTsCloudLabels(s.labels, slug, environment, "app"));
@@ -85883,6 +86048,16 @@ function shellQuote(value) {
85883
86048
  // src/drivers/shared/releases.ts
85884
86049
  var DEFAULT_SHARED_PATHS = ["storage", ".env"];
85885
86050
  var DEFAULT_KEEP_RELEASES = 4;
86051
+ var DEFAULT_KEEP_DEPLOY_LOGS = 20;
86052
+ function deployMetaDir(base) {
86053
+ return `${base.replace(/\/+$/, "")}/.ts-cloud`;
86054
+ }
86055
+ function deployHistoryPath(base) {
86056
+ return `${deployMetaDir(base)}/deploy-history.log`;
86057
+ }
86058
+ function deployLogPath(base, releaseId) {
86059
+ return `${deployMetaDir(base)}/deploys/${releaseId}.log`;
86060
+ }
85886
86061
  function releasePaths(base, releaseId) {
85887
86062
  const root = base.replace(/\/+$/, "");
85888
86063
  return {
@@ -85936,6 +86111,26 @@ function buildPruneReleases(paths, keep = DEFAULT_KEEP_RELEASES) {
85936
86111
  "done"
85937
86112
  ];
85938
86113
  }
86114
+ function buildDeployHistoryHeader(base, options) {
86115
+ const meta = deployMetaDir(base);
86116
+ const log4 = deployLogPath(base, options.releaseId);
86117
+ const history = deployHistoryPath(base);
86118
+ const keepLogs = Math.max(1, options.keepLogs ?? DEFAULT_KEEP_DEPLOY_LOGS);
86119
+ const commit = options.commit || "";
86120
+ const branch = options.branch || "";
86121
+ return [
86122
+ `mkdir -p ${meta}/deploys`,
86123
+ `exec > >(tee -a ${log4}) 2>&1`,
86124
+ `echo "[ts-cloud] deploy ${options.releaseId} commit=${commit} branch=${branch} starting $(date -u +%Y-%m-%dT%H:%M:%SZ)"`,
86125
+ "ts_cloud_record_deploy() {",
86126
+ " TS_CLOUD_RC=$?",
86127
+ ' if [ "$TS_CLOUD_RC" -eq 0 ]; then TS_CLOUD_ST=success; else TS_CLOUD_ST=failed; fi',
86128
+ ` printf '%s\\t%s\\t%s\\t%s\\trc=%s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${options.releaseId}" "${commit}" "$TS_CLOUD_ST" "$TS_CLOUD_RC" >> ${history}`,
86129
+ "}",
86130
+ "trap ts_cloud_record_deploy EXIT",
86131
+ `ls -1t ${meta}/deploys/*.log 2>/dev/null | tail -n +${keepLogs + 1} | while read -r TS_CLOUD_OLDLOG; do rm -f "$TS_CLOUD_OLDLOG"; done`
86132
+ ];
86133
+ }
85939
86134
 
85940
86135
  // src/drivers/shared/laravel-deploy.ts
85941
86136
  var MACRO_CREATE_RELEASE = "$CREATE_RELEASE";
@@ -86020,6 +86215,12 @@ function buildLaravelDeployScript(options) {
86020
86215
  "export COMPOSER_ALLOW_SUPERUSER=1",
86021
86216
  pantryEnvActivation()
86022
86217
  ];
86218
+ out.push(...buildDeployHistoryHeader(base, {
86219
+ releaseId,
86220
+ commit,
86221
+ branch: site.repository.branch,
86222
+ keepLogs: keepReleases
86223
+ }));
86023
86224
  out.push(...buildEnsureReleaseLayout(paths, sharedPaths));
86024
86225
  if (site.env && Object.keys(site.env).length > 0)
86025
86226
  out.push(...writeSharedEnv(`${paths.shared}/.env`, site.env));
@@ -86052,6 +86253,9 @@ function pantryExec(cmd) {
86052
86253
  function reEscape(value) {
86053
86254
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
86054
86255
  }
86256
+ function cronQuote(value) {
86257
+ return `'${value.split("'").join("'\\''")}'`;
86258
+ }
86055
86259
  function slugify(value) {
86056
86260
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
86057
86261
  }
@@ -86153,9 +86357,18 @@ function buildSiteServicesScript(options) {
86153
86357
  out.push(`systemctl enable ${name}.service`, `systemctl restart ${name}.service`);
86154
86358
  }
86155
86359
  const cronPath = schedulerCronPath(slug, siteName);
86156
- if (site.scheduler) {
86157
- const cron = `* * * * * root cd ${current} && ${PANTRY_ENV_EVAL} && ${phpBin} artisan schedule:run >> /dev/null 2>&1
86158
- `;
86360
+ const scheduler2 = site.scheduler;
86361
+ const schedulerEnabled = scheduler2 === true || typeof scheduler2 === "object" && scheduler2 !== null;
86362
+ if (schedulerEnabled) {
86363
+ const heartbeat = typeof scheduler2 === "object" && scheduler2 !== null ? scheduler2 : undefined;
86364
+ let command = `cd ${current} && ${PANTRY_ENV_EVAL} && ${phpBin} artisan schedule:run >> /dev/null 2>&1`;
86365
+ if (heartbeat?.heartbeatUrl) {
86366
+ const method = heartbeat.heartbeatMethod || "GET";
86367
+ const methodFlag = method === "GET" ? "" : `-X ${method} `;
86368
+ command += ` && curl -fsS -m 10 ${methodFlag}${cronQuote(heartbeat.heartbeatUrl)} >/dev/null 2>&1`;
86369
+ }
86370
+ const cron = `* * * * * root ${command}
86371
+ `.replace(/%/g, "\\%");
86159
86372
  out.push(`cat > ${cronPath} <<'TS_CLOUD_CRON_EOF'`, cron.replace(/\n$/, ""), "TS_CLOUD_CRON_EOF", `chmod 644 ${cronPath}`);
86160
86373
  } else {
86161
86374
  out.push(`rm -f ${cronPath}`);
@@ -86223,7 +86436,9 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86223
86436
  phpVersion,
86224
86437
  redirects: site.redirects,
86225
86438
  ssl: customCert,
86226
- auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined
86439
+ auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined,
86440
+ serverSnippet: resolveNginxSnippet(site.nginx, compute2?.nginxTemplates),
86441
+ clientMaxBodySize: site.nginx?.clientMaxBodySize
86227
86442
  }) : [];
86228
86443
  const sslScript = useNginx ? buildSslScript(site) : [];
86229
86444
  const servicesScript = siteHasServices(site) ? buildSiteServicesScript({ slug, siteName, site, phpVersion, appBase }) : [];
@@ -86286,7 +86501,9 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86286
86501
  appDir: `/var/www/${siteName}`,
86287
86502
  webDirectory: "",
86288
86503
  redirects: site.redirects,
86289
- auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined
86504
+ auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined,
86505
+ serverSnippet: resolveNginxSnippet(site.nginx, compute?.nginxTemplates),
86506
+ clientMaxBodySize: site.nginx?.clientMaxBodySize
86290
86507
  }) : [];
86291
86508
  const staticSsl = wantsNginxStatic ? buildSslScript(site) : [];
86292
86509
  const remoteScript = [...baseScript, ...staticVhost, ...staticSsl];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/ts-cloud",
3
3
  "type": "module",
4
- "version": "0.5.1",
4
+ "version": "0.5.3",
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.5.1",
93
- "@ts-cloud/core": "0.5.1",
92
+ "@ts-cloud/aws-types": "0.5.3",
93
+ "@ts-cloud/core": "0.5.3",
94
94
  "@stacksjs/ts-xml": "^0.1.0"
95
95
  },
96
96
  "devDependencies": {