@stacksjs/ts-cloud 0.4.2 → 0.5.1

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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Auto-deploy of the ts-cloud management dashboard (the `@ts-cloud/ui` stx app)
3
+ * on every server provision/deploy.
4
+ *
5
+ * Resolves the UI directory (the repo's local `ui/`, else the prebuilt UI that
6
+ * ships inside the installed package at `dist/ui`), derives a `dashboard.<apex>`
7
+ * host, and injects it into `config.sites` as a server-static site. It is served
8
+ * behind htpasswd ONLY when `TS_CLOUD_UI_PASSWORD` is set; when it is not, the
9
+ * dashboard is served without auth (no password is invented).
10
+ *
11
+ * Env:
12
+ * - `TS_CLOUD_UI_PASSWORD` htpasswd password (unset ⇒ no auth)
13
+ * - `TS_CLOUD_UI_USERNAME` htpasswd user (default `admin`)
14
+ * - `TS_CLOUD_UI_DOMAIN` explicit dashboard host (else `dashboard.<apex>`)
15
+ * - `TS_CLOUD_UI_REALM` browser auth realm
16
+ * - `TS_CLOUD_UI_DISABLE` set truthy to skip auto-deploy
17
+ */
18
+ import type { CloudConfig } from '@stacksjs/ts-cloud';
19
+ export interface EnsureDashboardLogger {
20
+ info: (msg: string) => void;
21
+ warn: (msg: string) => void;
22
+ }
23
+ /**
24
+ * Resolve the UI source to ship. Prefers the repo's local `ui/` (built on the
25
+ * deploy machine), then the prebuilt UI bundled in the installed package.
26
+ * Returns `{ uiRoot, build }` or null when no UI is available.
27
+ */
28
+ export declare function resolveUiSource(cwd: string): {
29
+ uiRoot: string;
30
+ build: string | false;
31
+ } | null;
32
+ /**
33
+ * Inject the management dashboard into `config.sites` for a server deploy.
34
+ * Mutates and returns `config`. Idempotent and safe to call on every deploy.
35
+ */
36
+ export declare function ensureManagementDashboard(config: CloudConfig, options?: {
37
+ cwd?: string;
38
+ logger?: EnsureDashboardLogger;
39
+ }): CloudConfig;
@@ -10,11 +10,13 @@
10
10
  * For the `custom` provider the vhost is rendered with the operator's cert
11
11
  * directly (see nginx-vhost's `ssl` option), so certbot is not involved.
12
12
  */
13
- import type { SiteConfig } from '@ts-cloud/core';
13
+ import type { SiteConfig, SslDnsConfig } from '@ts-cloud/core';
14
14
  /** Resolve the effective SSL provider for a site (Let's Encrypt by default when it has a domain). */
15
15
  export declare function resolveSslProvider(site: SiteConfig): 'letsencrypt' | 'custom' | 'none';
16
- /** Install certbot + the nginx plugin and ensure the auto-renew timer is enabled. */
17
- export declare function buildCertbotInstallScript(): string[];
16
+ /** Path of the certbot credentials INI for a DNS provider. */
17
+ export declare function dnsCredentialsPath(provider: SslDnsConfig['provider']): string;
18
+ /** Install certbot (+ optional DNS plugin) and ensure the auto-renew timer is enabled. */
19
+ export declare function buildCertbotInstallScript(dns?: SslDnsConfig): string[];
18
20
  export interface CertbotIssueOptions {
19
21
  /** Primary domain. */
20
22
  domain: string;
@@ -24,15 +26,27 @@ export interface CertbotIssueOptions {
24
26
  email?: string;
25
27
  /** Redirect HTTP → HTTPS (Forge default). @default true */
26
28
  redirect?: boolean;
29
+ /** Issue `*.<domain>` + `<domain>` (requires `dns`). */
30
+ wildcard?: boolean;
31
+ /** DNS-01 validation via a certbot DNS plugin (required for wildcard). */
32
+ dns?: SslDnsConfig;
27
33
  }
28
34
  /**
29
- * Issue (or expand) a Let's Encrypt cert for the domain via the nginx plugin.
30
- * Idempotent: `--keep-until-expiring` reuses a valid cert, so re-running on
31
- * every deploy is safe.
35
+ * Build the commands that write a DNS provider's certbot credentials INI
36
+ * (root-only) so certbot can create the `_acme-challenge` records. Returns `[]`
37
+ * when there are no credentials (e.g. route53 using instance-role creds).
38
+ */
39
+ export declare function buildDnsCredentialsScript(dns: SslDnsConfig): string[];
40
+ /**
41
+ * Issue (or expand) a Let's Encrypt cert. Uses the nginx (HTTP-01) plugin by
42
+ * default; with `dns` set, uses DNS-01 via the provider plugin (the only way to
43
+ * get a wildcard). Idempotent: `--keep-until-expiring` reuses a valid cert.
32
44
  */
33
45
  export declare function buildCertbotIssueScript(options: CertbotIssueOptions): string[];
34
46
  /**
35
47
  * Full SSL script for a site, dispatched on its provider. Returns `[]` for
36
- * `custom`/`none` (custom certs are baked into the vhost already).
48
+ * `custom`/`none` (custom certs are baked into the vhost already). A wildcard
49
+ * request without a DNS config is impossible (HTTP-01 can't do wildcards), so
50
+ * it's skipped.
37
51
  */
38
52
  export declare function buildSslScript(site: SiteConfig): string[];
@@ -14,7 +14,7 @@
14
14
  * (not-yet-live) release, so a failure leaves the previous release serving —
15
15
  * the Envoyer zero-downtime guarantee.
16
16
  */
17
- import type { SiteConfig } from '@ts-cloud/core';
17
+ import type { SiteConfig, SiteCredentialsConfig } from '@ts-cloud/core';
18
18
  export declare const MACRO_CREATE_RELEASE = "$CREATE_RELEASE";
19
19
  export declare const MACRO_ACTIVATE_RELEASE = "$ACTIVATE_RELEASE";
20
20
  export declare const MACRO_RESTART_QUEUES = "$RESTART_QUEUES";
@@ -35,6 +35,11 @@ export interface LaravelDeployOptions {
35
35
  /** PHP version selecting the `phpX.Y` binary. @default `site.phpVersion` ?? '8.3' */
36
36
  defaultPhpVersion?: string;
37
37
  }
38
+ /**
39
+ * Write private-registry credential files into the release before build:
40
+ * Composer `auth.json` and/or `.npmrc`. Quoted heredocs keep tokens literal.
41
+ */
42
+ export declare function buildCredentialFiles(releaseDir: string, creds?: SiteCredentialsConfig): string[];
38
43
  /**
39
44
  * Build the full remote shell script for a PHP/Laravel git deploy, expanding the
40
45
  * release macros. Requires `site.repository` to be set.
@@ -45,6 +45,17 @@ export declare function buildLinkSharedPaths(paths: ReleasePaths, sharedPaths?:
45
45
  * `mv -T`s it over `current` so there is no window where `current` is missing.
46
46
  */
47
47
  export declare function buildActivateRelease(paths: ReleasePaths): string[];
48
+ /**
49
+ * Roll the active release back to a previous one (Forge-style rollback). With
50
+ * `to` set, points `current` at `releases/<to>`; otherwise picks the most recent
51
+ * release that isn't the one `current` resolves to. Atomic (temp symlink + `mv
52
+ * -T`), and a no-op-safe guard fails loudly if the target is missing rather than
53
+ * leaving `current` dangling. The caller appends the engine reload
54
+ * (php-fpm/queues) — see {@link import('./laravel-deploy')}.
55
+ */
56
+ export declare function buildRollbackScript(paths: ReleasePaths, options?: {
57
+ to?: string;
58
+ }): string[];
48
59
  /**
49
60
  * Remove all but the newest `keep` releases (by mtime). `current` always points
50
61
  * at the newest, so it is never pruned.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Server "recipes" — reusable bash scripts run on demand across one or more
3
+ * provisioned servers, as a chosen user (Forge's Recipes feature). The driver
4
+ * fans a recipe out over the target servers (SSH/SSM); this module builds the
5
+ * single wrapped script that runs on each box: it sets a strict shell, runs the
6
+ * recipe body as the requested user with a login shell (so PATH/pantry env are
7
+ * loaded), and prints clear begin/end markers + the exit code so captured
8
+ * output is easy to scan.
9
+ */
10
+ export interface ServerRecipeOptions {
11
+ /** Recipe name (shown in the output markers). */
12
+ name: string;
13
+ /** The recipe body — bash command lines run on the server. */
14
+ script: string[];
15
+ /**
16
+ * User to run the recipe as. Defaults to `root`. A non-root user is invoked
17
+ * via `runuser -l` so it gets a login shell (profile + PATH, incl. pantry).
18
+ */
19
+ user?: string;
20
+ }
21
+ /**
22
+ * Build the wrapped recipe script run on a server. The body is written to a
23
+ * temp file and executed as `user`; the wrapper captures the exit code and
24
+ * prints `__TS_CLOUD_RECIPE_*__` markers around the run for the driver to parse.
25
+ */
26
+ export declare function buildServerRecipeScript(options: ServerRecipeOptions): string[];
package/dist/index.js CHANGED
@@ -7654,6 +7654,56 @@ function createDashboardSite(options) {
7654
7654
  }
7655
7655
  };
7656
7656
  }
7657
+ function apexOf(domain) {
7658
+ const parts = domain.split(".").filter(Boolean);
7659
+ return parts.length <= 2 ? domain : parts.slice(-2).join(".");
7660
+ }
7661
+ function resolveDashboardDomain(config6, environment, explicit) {
7662
+ if (explicit)
7663
+ return explicit;
7664
+ const candidates = [];
7665
+ for (const site of Object.values(config6.sites ?? {})) {
7666
+ const d = site?.domain;
7667
+ if (typeof d === "string")
7668
+ candidates.push(d);
7669
+ else if (Array.isArray(d))
7670
+ candidates.push(...d.filter((x) => typeof x === "string"));
7671
+ }
7672
+ if (environment && config6.environments?.[environment]?.domain)
7673
+ candidates.push(config6.environments[environment].domain);
7674
+ const dnsDomain = config6.infrastructure?.dns?.domain;
7675
+ if (dnsDomain)
7676
+ candidates.push(dnsDomain);
7677
+ const base = candidates.find((d) => d && !d.startsWith("dashboard."));
7678
+ if (!base)
7679
+ return null;
7680
+ return `dashboard.${apexOf(base)}`;
7681
+ }
7682
+ function hasManagementDashboardSite(config6) {
7683
+ return Object.entries(config6.sites ?? {}).some(([name, site]) => {
7684
+ if (!site)
7685
+ return false;
7686
+ const root = site.root ?? "";
7687
+ return name === "dashboard" || root === "ui/dist" || root.endsWith("/ui/dist") || root.endsWith("/dist/ui");
7688
+ });
7689
+ }
7690
+ function resolveManagementDashboardSite(config6, environment, opts) {
7691
+ if (hasManagementDashboardSite(config6))
7692
+ return null;
7693
+ const domain = resolveDashboardDomain(config6, environment, opts.domain);
7694
+ if (!domain)
7695
+ return null;
7696
+ const site = {
7697
+ root: opts.uiRoot,
7698
+ deploy: "server",
7699
+ type: "static",
7700
+ domain,
7701
+ ssl: { provider: "letsencrypt" },
7702
+ ...opts.build === false || opts.build === undefined ? {} : { build: opts.build },
7703
+ ...opts.password ? { auth: { username: opts.username || "admin", password: opts.password, realm: opts.realm } } : {}
7704
+ };
7705
+ return { name: "dashboard", site };
7706
+ }
7657
7707
  function deepMerge5(target, source) {
7658
7708
  const result = { ...target };
7659
7709
  for (const key in source) {
@@ -25896,6 +25946,7 @@ function composeServerlessAppTemplate(opts) {
25896
25946
  TSCLOUD_LAMBDA_MODE: mode,
25897
25947
  TSCLOUD_ENV: environment,
25898
25948
  ...app.octane ? { TSCLOUD_OCTANE: "1" } : {},
25949
+ ...app.scheduler === "sub-minute" ? { TSCLOUD_SCHEDULER: "sub-minute" } : {},
25899
25950
  ...cacheEnabled ? { TSCLOUD_CACHE_TABLE: `${slug}-${environment}-cache` } : {},
25900
25951
  ...hasQueue ? { TSCLOUD_QUEUE: queueNames[0] } : {},
25901
25952
  ...app.env ?? {}
@@ -25915,7 +25966,7 @@ function composeServerlessAppTemplate(opts) {
25915
25966
  ]
25916
25967
  }
25917
25968
  } : {};
25918
- function addFunction(logicalId, name, handler8, mode, memory, timeout, reservedConcurrency) {
25969
+ function addFunction(logicalId, name, handler8, mode, memory, timeout, reservedConcurrency, tmp = tmpStorage) {
25919
25970
  resources[`${logicalId}LogGroup`] = {
25920
25971
  Type: "AWS::Logs::LogGroup",
25921
25972
  Properties: { LogGroupName: `/aws/lambda/${name}`, RetentionInDays: 14 }
@@ -25940,17 +25991,17 @@ function composeServerlessAppTemplate(opts) {
25940
25991
  Timeout: timeout,
25941
25992
  Role: Fn2.getAtt("AppRole", "Arn"),
25942
25993
  Environment: { Variables: baseEnv(mode) },
25943
- EphemeralStorage: { Size: tmpStorage },
25994
+ EphemeralStorage: { Size: tmp },
25944
25995
  ...codeProps,
25945
25996
  ...reservedConcurrency !== undefined ? { ReservedConcurrentExecutions: reservedConcurrency } : {},
25946
25997
  ...vpcConfig
25947
25998
  }
25948
25999
  };
25949
26000
  }
25950
- addFunction("HttpFunction", functionNames.http, handlers.http, "http", app.memory ?? 1024, app.timeout ?? 28, app.concurrency);
25951
- addFunction("CliFunction", functionNames.cli, handlers.cli, "cli", app.cliMemory ?? 1024, app.cliTimeout ?? 900);
26001
+ addFunction("HttpFunction", functionNames.http, handlers.http, "http", app.memory ?? 1024, app.timeout ?? 28, app.concurrency, tmpStorage);
26002
+ addFunction("CliFunction", functionNames.cli, handlers.cli, "cli", app.cliMemory ?? 1024, app.cliTimeout ?? 900, undefined, app.cliTmpStorage ?? tmpStorage);
25952
26003
  if (hasQueue)
25953
- addFunction("QueueFunction", functionNames.queue, handlers.queue, "queue", app.queueMemory ?? 1024, app.queueTimeout ?? 120);
26004
+ addFunction("QueueFunction", functionNames.queue, handlers.queue, "queue", app.queueMemory ?? 1024, app.queueTimeout ?? 120, undefined, app.queueTmpStorage ?? tmpStorage);
25954
26005
  resources.HttpApi = {
25955
26006
  Type: "AWS::ApiGatewayV2::Api",
25956
26007
  Properties: {
@@ -25997,6 +26048,59 @@ function composeServerlessAppTemplate(opts) {
25997
26048
  Value: Fn2.getAtt("HttpApi", "ApiEndpoint")
25998
26049
  };
25999
26050
  outputs.HttpApiId = { Description: "HTTP API id", Value: Fn2.ref("HttpApi") };
26051
+ const domains = (Array.isArray(app.domain) ? app.domain : app.domain ? [app.domain] : []).filter(Boolean);
26052
+ if (domains.length) {
26053
+ if (!app.certificateArn && !app.hostedZoneId) {
26054
+ throw new Error("serverless app: a custom `domain` needs either `certificateArn` (pre-issued, regional) or `hostedZoneId` (to auto-issue + validate an ACM cert).");
26055
+ }
26056
+ let certRef = app.certificateArn;
26057
+ if (!certRef) {
26058
+ resources.HttpCertificate = {
26059
+ Type: "AWS::CertificateManager::Certificate",
26060
+ Properties: {
26061
+ DomainName: domains[0],
26062
+ ...domains.length > 1 ? { SubjectAlternativeNames: domains.slice(1) } : {},
26063
+ ValidationMethod: "DNS",
26064
+ DomainValidationOptions: domains.map((d) => ({ DomainName: d, HostedZoneId: app.hostedZoneId }))
26065
+ }
26066
+ };
26067
+ certRef = Fn2.ref("HttpCertificate");
26068
+ }
26069
+ domains.forEach((d, i) => {
26070
+ const dn = `HttpDomain${i}`;
26071
+ resources[dn] = {
26072
+ Type: "AWS::ApiGatewayV2::DomainName",
26073
+ Properties: {
26074
+ DomainName: d,
26075
+ DomainNameConfigurations: [{ CertificateArn: certRef, EndpointType: "REGIONAL" }]
26076
+ }
26077
+ };
26078
+ resources[`HttpApiMapping${i}`] = {
26079
+ Type: "AWS::ApiGatewayV2::ApiMapping",
26080
+ DependsOn: ["HttpStage"],
26081
+ Properties: { ApiId: Fn2.ref("HttpApi"), DomainName: Fn2.ref(dn), Stage: "$default" }
26082
+ };
26083
+ if (app.hostedZoneId) {
26084
+ resources[`HttpDomainRecord${i}`] = {
26085
+ Type: "AWS::Route53::RecordSet",
26086
+ Properties: {
26087
+ HostedZoneId: app.hostedZoneId,
26088
+ Name: d,
26089
+ Type: "A",
26090
+ AliasTarget: {
26091
+ DNSName: Fn2.getAtt(dn, "RegionalDomainName"),
26092
+ HostedZoneId: Fn2.getAtt(dn, "RegionalHostedZoneId")
26093
+ }
26094
+ }
26095
+ };
26096
+ }
26097
+ outputs[`CustomDomain${i}`] = { Description: `Custom domain ${d}`, Value: d };
26098
+ outputs[`CustomDomainTarget${i}`] = {
26099
+ Description: `Point ${d} (CNAME/alias) at this APIGW regional domain`,
26100
+ Value: Fn2.getAtt(dn, "RegionalDomainName")
26101
+ };
26102
+ });
26103
+ }
26000
26104
  if (hasQueue) {
26001
26105
  resources.AppQueueDlq = {
26002
26106
  Type: "AWS::SQS::Queue",
@@ -83437,28 +83541,63 @@ function buildDatabaseSetupScript(database, services = {}) {
83437
83541
  if (usePostgres && !useMysql && !useMariadb) {
83438
83542
  const pgIdent = (v) => `"${v.replace(/"/g, '""')}"`;
83439
83543
  const pgLit = (v) => `'${v.replace(/'/g, "''")}'`;
83544
+ const pgEnsureRole = (u, p) => [
83545
+ "DO $$ BEGIN",
83546
+ ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${pgLit(u)}) THEN`,
83547
+ ` CREATE ROLE ${pgIdent(u)} LOGIN PASSWORD ${pgLit(p)};`,
83548
+ " ELSE",
83549
+ ` ALTER ROLE ${pgIdent(u)} LOGIN PASSWORD ${pgLit(p)};`,
83550
+ " END IF;",
83551
+ "END $$;"
83552
+ ];
83553
+ const pgGrant = (u) => {
83554
+ const dbs = u.databases && u.databases.length > 0 ? u.databases : [name];
83555
+ const lines = [];
83556
+ for (const db of dbs) {
83557
+ if (u.access === "readonly") {
83558
+ lines.push(`GRANT CONNECT ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`, `\\connect ${pgIdent(db)}`, `GRANT USAGE ON SCHEMA public TO ${pgIdent(u.username)};`, `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${pgIdent(u.username)};`, `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${pgIdent(u.username)};`, "\\connect postgres");
83559
+ } else {
83560
+ lines.push(`GRANT ALL PRIVILEGES ON DATABASE ${pgIdent(db)} TO ${pgIdent(u.username)};`);
83561
+ }
83562
+ }
83563
+ return lines;
83564
+ };
83565
+ const extraUsers2 = database.users || [];
83440
83566
  return [
83441
83567
  pantryEnvActivation(),
83442
83568
  "for i in $(seq 1 30); do pg_isready -h 127.0.0.1 -p 5432 -q && break; sleep 2; done",
83443
83569
  "psql -h 127.0.0.1 -p 5432 -U postgres <<'TS_CLOUD_PG_EOF'",
83444
- "DO $$ BEGIN",
83445
- ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${pgLit(user)}) THEN`,
83446
- ` CREATE ROLE ${pgIdent(user)} LOGIN PASSWORD ${pgLit(pass)};`,
83447
- " END IF;",
83448
- "END $$;",
83570
+ ...pgEnsureRole(user, pass),
83449
83571
  `SELECT 'CREATE DATABASE ${pgIdent(name)} OWNER ${pgIdent(user)}' ` + `WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = ${pgLit(name)})\\gexec`,
83572
+ ...extraUsers2.flatMap((u) => [...pgEnsureRole(u.username, u.password), ...pgGrant(u)]),
83450
83573
  "TS_CLOUD_PG_EOF"
83451
83574
  ];
83452
83575
  }
83576
+ const sock = useMariadb ? "/var/lib/pantry/mariadb/mariadbd.sock" : "/var/lib/pantry/mysql/mysqld.sock";
83453
83577
  const ident = (v) => v.replace(/`/g, "``");
83454
83578
  const lit = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
83579
+ const mysqlUser = (u) => {
83580
+ const dbs = u.databases && u.databases.length > 0 ? u.databases : [name];
83581
+ const priv = u.access === "readonly" ? "SELECT" : "ALL PRIVILEGES";
83582
+ const lines = [
83583
+ `CREATE USER IF NOT EXISTS '${lit(u.username)}'@'%' IDENTIFIED BY '${lit(u.password)}';`,
83584
+ `CREATE USER IF NOT EXISTS '${lit(u.username)}'@'localhost' IDENTIFIED BY '${lit(u.password)}';`,
83585
+ `ALTER USER '${lit(u.username)}'@'%' IDENTIFIED BY '${lit(u.password)}';`,
83586
+ `ALTER USER '${lit(u.username)}'@'localhost' IDENTIFIED BY '${lit(u.password)}';`
83587
+ ];
83588
+ for (const db of dbs) {
83589
+ lines.push(`GRANT ${priv} ON \`${ident(db)}\`.* TO '${lit(u.username)}'@'%';`, `GRANT ${priv} ON \`${ident(db)}\`.* TO '${lit(u.username)}'@'localhost';`);
83590
+ }
83591
+ return lines;
83592
+ };
83593
+ const extraUsers = database.users || [];
83455
83594
  return [
83456
83595
  pantryEnvActivation(),
83457
- "for i in $(seq 1 30); do mysqladmin -h 127.0.0.1 -P 3306 -u root ping 2>/dev/null | grep -q alive && break; sleep 2; done",
83458
- "mysql -h 127.0.0.1 -P 3306 -u root <<'TS_CLOUD_SQL_EOF'",
83596
+ `for i in $(seq 1 30); do mysqladmin --socket=${sock} -u root ping 2>/dev/null | grep -q alive && break; sleep 2; done`,
83597
+ `mysql --socket=${sock} -u root <<'TS_CLOUD_SQL_EOF'`,
83459
83598
  `CREATE DATABASE IF NOT EXISTS \`${ident(name)}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`,
83460
- `CREATE USER IF NOT EXISTS '${lit(user)}'@'%' IDENTIFIED BY '${lit(pass)}';`,
83461
- `GRANT ALL PRIVILEGES ON \`${ident(name)}\`.* TO '${lit(user)}'@'%';`,
83599
+ ...mysqlUser({ username: user, password: pass }),
83600
+ ...extraUsers.flatMap(mysqlUser),
83462
83601
  "FLUSH PRIVILEGES;",
83463
83602
  "TS_CLOUD_SQL_EOF"
83464
83603
  ];
@@ -83677,6 +83816,12 @@ function buildNginxServiceScript(projectDir = "/opt/pantry") {
83677
83816
  " fastcgi_temp_path /var/lib/nginx/fastcgi;",
83678
83817
  " uwsgi_temp_path /var/lib/nginx/uwsgi;",
83679
83818
  " scgi_temp_path /var/lib/nginx/scgi;",
83819
+ " server {",
83820
+ " listen 80 default_server;",
83821
+ " listen [::]:80 default_server;",
83822
+ " server_name _;",
83823
+ " return 444;",
83824
+ " }",
83680
83825
  " include /etc/nginx/sites-enabled/*;",
83681
83826
  "}",
83682
83827
  "TS_CLOUD_NGINXCONF_EOF",
@@ -85620,10 +85765,22 @@ function resolveSslProvider(site) {
85620
85765
  return site.ssl.provider;
85621
85766
  return site.domain ? "letsencrypt" : "none";
85622
85767
  }
85623
- function buildCertbotInstallScript() {
85768
+ var DNS_PLUGINS = {
85769
+ cloudflare: { pkg: "python3-certbot-dns-cloudflare", plugin: "dns-cloudflare" },
85770
+ route53: { pkg: "python3-certbot-dns-route53", plugin: "dns-route53" },
85771
+ digitalocean: { pkg: "python3-certbot-dns-digitalocean", plugin: "dns-digitalocean" },
85772
+ google: { pkg: "python3-certbot-dns-google", plugin: "dns-google" }
85773
+ };
85774
+ function dnsCredentialsPath(provider) {
85775
+ return `/etc/letsencrypt/ts-cloud-${provider}.ini`;
85776
+ }
85777
+ function buildCertbotInstallScript(dns2) {
85778
+ const plugin = dns2 ? DNS_PLUGINS[dns2.provider] : undefined;
85779
+ const pkgs = ["certbot", "python3-certbot-nginx", ...plugin ? [plugin.pkg] : []].join(" ");
85780
+ const installLine = plugin ? `apt-get update -y && apt-get install -y ${pkgs}` : `command -v certbot >/dev/null 2>&1 || { apt-get update -y && apt-get install -y ${pkgs}; }`;
85624
85781
  return [
85625
85782
  "export DEBIAN_FRONTEND=noninteractive",
85626
- "command -v certbot >/dev/null 2>&1 || { apt-get update -y && apt-get install -y certbot python3-certbot-nginx; }",
85783
+ installLine,
85627
85784
  "mkdir -p /etc/letsencrypt/renewal-hooks/deploy",
85628
85785
  "cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'TS_CLOUD_HOOK_EOF'",
85629
85786
  "#!/bin/sh",
@@ -85634,15 +85791,34 @@ function buildCertbotInstallScript() {
85634
85791
  "systemctl start certbot.timer 2>/dev/null || true"
85635
85792
  ];
85636
85793
  }
85637
- function buildCertbotIssueScript(options) {
85638
- const domains = [options.domain, ...options.aliases || []].filter(Boolean);
85639
- const args = [
85640
- "certbot --nginx",
85641
- "--non-interactive",
85642
- "--agree-tos",
85643
- "--keep-until-expiring",
85644
- options.redirect === false ? "--no-redirect" : "--redirect"
85794
+ function buildDnsCredentialsScript(dns2) {
85795
+ if (!dns2.credentials || Object.keys(dns2.credentials).length === 0)
85796
+ return [];
85797
+ const file = dnsCredentialsPath(dns2.provider);
85798
+ const lines = Object.entries(dns2.credentials).map(([k, v]) => `${k} = ${v}`).join(`
85799
+ `);
85800
+ return [
85801
+ `cat > ${file} <<'TS_CLOUD_DNSCREDS_EOF'`,
85802
+ lines,
85803
+ "TS_CLOUD_DNSCREDS_EOF",
85804
+ `chmod 600 ${file}`
85645
85805
  ];
85806
+ }
85807
+ function buildCertbotIssueScript(options) {
85808
+ const dns2 = options.dns;
85809
+ const domains = options.wildcard ? [`*.${options.domain}`, options.domain] : [options.domain, ...options.aliases || []].filter(Boolean);
85810
+ const args = ["certbot", "--non-interactive", "--agree-tos", "--keep-until-expiring"];
85811
+ if (dns2) {
85812
+ const plugin = DNS_PLUGINS[dns2.provider].plugin;
85813
+ args.push(`--${plugin}`);
85814
+ if (dns2.provider !== "route53" && dns2.credentials)
85815
+ args.push(`--${plugin}-credentials ${dnsCredentialsPath(dns2.provider)}`);
85816
+ if (typeof dns2.propagationSeconds === "number" && dns2.provider !== "route53")
85817
+ args.push(`--${plugin}-propagation-seconds ${dns2.propagationSeconds}`);
85818
+ args.push("certonly");
85819
+ } else {
85820
+ args.push("--nginx", options.redirect === false ? "--no-redirect" : "--redirect");
85821
+ }
85646
85822
  if (options.email)
85647
85823
  args.push(`-m ${options.email}`);
85648
85824
  else
@@ -85654,12 +85830,20 @@ function buildCertbotIssueScript(options) {
85654
85830
  function buildSslScript(site) {
85655
85831
  if (resolveSslProvider(site) !== "letsencrypt" || !site.domain)
85656
85832
  return [];
85833
+ const ssl = site.ssl;
85834
+ const dns2 = ssl?.dns;
85835
+ const wildcard = ssl?.wildcard === true;
85836
+ if (wildcard && !dns2)
85837
+ return [];
85657
85838
  return [
85658
- ...buildCertbotInstallScript(),
85839
+ ...buildCertbotInstallScript(dns2),
85840
+ ...dns2 ? buildDnsCredentialsScript(dns2) : [],
85659
85841
  ...buildCertbotIssueScript({
85660
85842
  domain: site.domain,
85661
85843
  aliases: site.aliases,
85662
- email: site.ssl?.email
85844
+ email: ssl?.email,
85845
+ wildcard,
85846
+ dns: dns2
85663
85847
  })
85664
85848
  ];
85665
85849
  }
@@ -85804,6 +85988,21 @@ function writeSharedEnv(sharedEnvPath, env2) {
85804
85988
  `chmod 600 ${sharedEnvPath}`
85805
85989
  ];
85806
85990
  }
85991
+ function writeFileHeredoc2(path, body, marker) {
85992
+ return [`cat > ${path} <<'${marker}'`, body.replace(/\n$/, ""), marker, `chmod 600 ${path}`];
85993
+ }
85994
+ function buildCredentialFiles(releaseDir, creds) {
85995
+ if (!creds)
85996
+ return [];
85997
+ const out = [];
85998
+ if (creds.composerAuth) {
85999
+ const json = typeof creds.composerAuth === "string" ? creds.composerAuth : JSON.stringify(creds.composerAuth, null, 2);
86000
+ out.push(...writeFileHeredoc2(`${releaseDir}/auth.json`, json, "TS_CLOUD_AUTHJSON_EOF"));
86001
+ }
86002
+ if (creds.npmrc)
86003
+ out.push(...writeFileHeredoc2(`${releaseDir}/.npmrc`, creds.npmrc, "TS_CLOUD_NPMRC_EOF"));
86004
+ return out;
86005
+ }
85807
86006
  function buildLaravelDeployScript(options) {
85808
86007
  const { siteName, site, releaseId, commit } = options;
85809
86008
  if (!site.repository?.url)
@@ -85830,6 +86029,7 @@ function buildLaravelDeployScript(options) {
85830
86029
  out.push(...buildGitCheckoutScript({ repository: site.repository, releaseDir: paths.release, commit }));
85831
86030
  out.push(...buildLinkSharedPaths(paths, sharedPaths));
85832
86031
  out.push(`cd ${paths.release}`);
86032
+ out.push(...buildCredentialFiles(paths.release, site.credentials));
85833
86033
  out.push(`chown -R www-data:www-data ${paths.shared}/storage 2>/dev/null || true`, `[ -d ${paths.release}/bootstrap/cache ] && chown -R www-data:www-data ${paths.release}/bootstrap/cache 2>/dev/null || true`, `chmod -R ug+rwX ${paths.shared}/storage 2>/dev/null || true`);
85834
86034
  } else if (line === MACRO_ACTIVATE_RELEASE) {
85835
86035
  out.push(...buildActivateRelease(paths));
@@ -86275,9 +86475,11 @@ export {
86275
86475
  resolveQueueNames,
86276
86476
  resolveProjectStackName,
86277
86477
  resolveObjectStorage,
86478
+ resolveManagementDashboardSite,
86278
86479
  resolveHetznerApiToken,
86279
86480
  resolveExecStart,
86280
86481
  resolveDeployBucketName,
86482
+ resolveDashboardDomain,
86281
86483
  resolveCredentials,
86282
86484
  resolveCloudProvider,
86283
86485
  resolveApp,
@@ -86340,6 +86542,7 @@ export {
86340
86542
  hashFile,
86341
86543
  hashDirectory,
86342
86544
  hashBuffer,
86545
+ hasManagementDashboardSite,
86343
86546
  guardDutyManager,
86344
86547
  globalResourceManager,
86345
86548
  getTimestamp,
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Page Not Found</title>
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
9
+ .container { text-align: center; }
10
+ h1 { font-size: 6rem; margin: 0; color: #333; }
11
+ p { color: #666; }
12
+ a { color: #0066cc; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <div class="container">
17
+ <h1>404</h1>
18
+ <p>Page not found</p>
19
+ <a href="/">Go home</a>
20
+ </div>
21
+ </body>
22
+ </html>