@stacksjs/ts-cloud 0.5.26 → 0.5.28

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 (79) hide show
  1. package/dist/bin/cli.js +1223 -7366
  2. package/dist/deploy/dashboard-data-server.d.ts +27 -5
  3. package/dist/deploy/dashboard-database.d.ts +38 -0
  4. package/dist/deploy/dashboard-database.test.d.ts +1 -0
  5. package/dist/deploy/dashboard-operations.d.ts +48 -0
  6. package/dist/deploy/dashboard-operations.test.d.ts +1 -0
  7. package/dist/deploy/index.d.ts +1 -0
  8. package/dist/deploy/local-dashboard-server.d.ts +31 -0
  9. package/dist/deploy/management-dashboard.d.ts +21 -1
  10. package/dist/deploy/site-config-editor.d.ts +37 -0
  11. package/dist/deploy/site-target.d.ts +3 -1
  12. package/dist/deploy/ssh-config-editor.d.ts +22 -0
  13. package/dist/deploy/ssh-config-editor.test.d.ts +1 -0
  14. package/dist/drivers/factory.d.ts +4 -0
  15. package/dist/drivers/index.d.ts +1 -0
  16. package/dist/drivers/local-box/driver.d.ts +20 -0
  17. package/dist/drivers/shared/compute-deploy.d.ts +2 -0
  18. package/dist/drivers/shared/deploy-script.d.ts +28 -12
  19. package/dist/drivers/shared/rpx-gateway.d.ts +99 -2
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.js +3381 -210
  22. package/dist/ui/index.html +83 -1
  23. package/dist/ui/server/actions.html +1193 -0
  24. package/dist/ui/server/activity.html +1132 -0
  25. package/dist/ui/server/backups.html +57 -1
  26. package/dist/ui/server/database.html +1208 -0
  27. package/dist/ui/server/deployments.html +79 -1
  28. package/dist/ui/server/diagnostics.html +1132 -0
  29. package/dist/ui/server/logs.html +1165 -0
  30. package/dist/ui/server/metrics.html +1 -1
  31. package/dist/ui/server/security.html +1132 -0
  32. package/dist/ui/server/services.html +57 -1
  33. package/dist/ui/server/sites.html +141 -1
  34. package/dist/ui/server/ssh-keys.html +58 -1
  35. package/dist/ui/server/workers.html +57 -1
  36. package/dist/ui/serverless/assets.html +1 -1
  37. package/dist/ui/serverless/cost.html +1 -1
  38. package/dist/ui/serverless/data.html +1 -1
  39. package/dist/ui/serverless/deployments.html +1 -1
  40. package/dist/ui/serverless/firewall.html +1 -1
  41. package/dist/ui/serverless/functions.html +1 -1
  42. package/dist/ui/serverless/logs.html +1165 -0
  43. package/dist/ui/serverless/metrics.html +1 -1
  44. package/dist/ui/serverless/queues.html +1 -1
  45. package/dist/ui/serverless/scheduler.html +1 -1
  46. package/dist/ui/serverless/secrets.html +1 -1
  47. package/dist/ui/serverless.html +1 -1
  48. package/dist/ui-src/package.json +5 -0
  49. package/dist/ui-src/pages/index.stx +308 -0
  50. package/dist/ui-src/pages/partials/head.stx +294 -0
  51. package/dist/ui-src/pages/partials/nav.stx +54 -0
  52. package/dist/ui-src/pages/partials/op-confirm.stx +7 -0
  53. package/dist/ui-src/pages/server/actions.stx +60 -0
  54. package/dist/ui-src/pages/server/activity.stx +55 -0
  55. package/dist/ui-src/pages/server/backups.stx +91 -0
  56. package/dist/ui-src/pages/server/database.stx +119 -0
  57. package/dist/ui-src/pages/server/deployments.stx +106 -0
  58. package/dist/ui-src/pages/server/diagnostics.stx +88 -0
  59. package/dist/ui-src/pages/server/logs.stx +84 -0
  60. package/dist/ui-src/pages/server/metrics.stx +183 -0
  61. package/dist/ui-src/pages/server/security.stx +127 -0
  62. package/dist/ui-src/pages/server/services.stx +82 -0
  63. package/dist/ui-src/pages/server/sites.stx +143 -0
  64. package/dist/ui-src/pages/server/ssh-keys.stx +93 -0
  65. package/dist/ui-src/pages/server/workers.stx +99 -0
  66. package/dist/ui-src/pages/serverless/assets.stx +66 -0
  67. package/dist/ui-src/pages/serverless/cost.stx +149 -0
  68. package/dist/ui-src/pages/serverless/data.stx +76 -0
  69. package/dist/ui-src/pages/serverless/deployments.stx +56 -0
  70. package/dist/ui-src/pages/serverless/firewall.stx +58 -0
  71. package/dist/ui-src/pages/serverless/functions.stx +118 -0
  72. package/dist/ui-src/pages/serverless/logs.stx +84 -0
  73. package/dist/ui-src/pages/serverless/metrics.stx +76 -0
  74. package/dist/ui-src/pages/serverless/queues.stx +81 -0
  75. package/dist/ui-src/pages/serverless/scheduler.stx +60 -0
  76. package/dist/ui-src/pages/serverless/secrets.stx +55 -0
  77. package/dist/ui-src/pages/serverless.stx +222 -0
  78. package/dist/ui-src/src/charts.ts +17333 -0
  79. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -18,8 +18,8 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
18
18
 
19
19
  // ../core/dist/index.js
20
20
  import { createRequire as createRequire2 } from "node:module";
21
- import { existsSync as existsSync11, readdirSync as readdirSync6 } from "node:fs";
22
- import { join as join7 } from "node:path";
21
+ import { existsSync as existsSync14, readdirSync as readdirSync6 } from "node:fs";
22
+ import { join as join9 } from "node:path";
23
23
  import { createHash } from "node:crypto";
24
24
  import { readFileSync as readFileSync2, readdirSync as readdirSync22, statSync as statSync3, existsSync as existsSync23, writeFileSync as writeFileSync8, copyFileSync } from "node:fs";
25
25
  import { join as join22, basename, dirname as dirname5, extname as extname2 } from "node:path";
@@ -50,7 +50,7 @@ import { fileURLToPath as fileURLToPath3 } from "node:url";
50
50
  import { execFileSync as execFileSync2 } from "node:child_process";
51
51
  import { existsSync as existsSync52, mkdirSync as mkdirSync22, mkdtempSync as mkdtempSync3, readdirSync as readdirSync52, readFileSync as readFileSync6, rmSync as rmSync3, statSync as statSync32, writeFileSync as writeFileSync52 } from "node:fs";
52
52
  import { tmpdir as tmpdir3 } from "node:os";
53
- import { join as join9, relative as relative22 } from "node:path";
53
+ import { join as join92, relative as relative22 } from "node:path";
54
54
  import { execSync as execSync2 } from "node:child_process";
55
55
  import { createHash as createHash5 } from "node:crypto";
56
56
  import { readdirSync as readdirSync62, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
@@ -7693,6 +7693,21 @@ function resolveManagementDashboardSite(config6, environment, opts) {
7693
7693
  const domain = resolveDashboardDomain(config6, environment, opts.domain);
7694
7694
  if (!domain)
7695
7695
  return null;
7696
+ const auth2 = opts.password ? { auth: { username: opts.username || "admin", password: opts.password, realm: opts.realm } } : {};
7697
+ if (opts.live) {
7698
+ const port = opts.port ?? 7676;
7699
+ const site2 = {
7700
+ root: opts.uiRoot,
7701
+ deploy: "server",
7702
+ domain,
7703
+ start: `cloud dashboard:serve --box --host 127.0.0.1 --port ${port}`,
7704
+ port,
7705
+ ssl: { provider: "letsencrypt" },
7706
+ ...opts.build === false || opts.build === undefined ? {} : { build: opts.build },
7707
+ ...auth2
7708
+ };
7709
+ return { name: "dashboard", site: site2 };
7710
+ }
7696
7711
  const site = {
7697
7712
  root: opts.uiRoot,
7698
7713
  deploy: "server",
@@ -7700,7 +7715,7 @@ function resolveManagementDashboardSite(config6, environment, opts) {
7700
7715
  domain,
7701
7716
  ssl: { provider: "letsencrypt" },
7702
7717
  ...opts.build === false || opts.build === undefined ? {} : { build: opts.build },
7703
- ...opts.password ? { auth: { username: opts.username || "admin", password: opts.password, realm: opts.realm } } : {}
7718
+ ...auth2
7704
7719
  };
7705
7720
  return { name: "dashboard", site };
7706
7721
  }
@@ -26931,7 +26946,7 @@ function laravelServerlessEnvDefaults(opts = {}) {
26931
26946
  }
26932
26947
  function* walk(dir) {
26933
26948
  for (const entry of readdirSync52(dir)) {
26934
- const full = join9(dir, entry);
26949
+ const full = join92(dir, entry);
26935
26950
  if (statSync32(full).isDirectory())
26936
26951
  yield* walk(full);
26937
26952
  else
@@ -26942,22 +26957,22 @@ function buildPhpRuntimeLayerZip(options = {}) {
26942
26957
  const architecture = options.architecture ?? "x86_64";
26943
26958
  const platform = options.platform ?? (architecture === "arm64" ? "linux/arm64" : "linux/amd64");
26944
26959
  const step = options.onStep ?? (() => {});
26945
- const stage = mkdtempSync3(join9(tmpdir3(), "tscloud-php-layer-"));
26960
+ const stage = mkdtempSync3(join92(tmpdir3(), "tscloud-php-layer-"));
26946
26961
  const imageTag = "tscloud-php-layer:build";
26947
26962
  try {
26948
- writeFileSync52(join9(stage, "Dockerfile"), generatePhpLayerDockerfile(options));
26963
+ writeFileSync52(join92(stage, "Dockerfile"), generatePhpLayerDockerfile(options));
26949
26964
  step("Building PHP runtime image (docker)");
26950
26965
  execFileSync2("docker", ["build", "--platform", platform, "-t", imageTag, stage], { stdio: "inherit" });
26951
26966
  step("Extracting /opt/php from image");
26952
26967
  const cid = execFileSync2("docker", ["create", "--platform", platform, imageTag], { encoding: "utf-8" }).trim();
26953
- const optDir = join9(stage, "opt");
26968
+ const optDir = join92(stage, "opt");
26954
26969
  mkdirSync22(optDir, { recursive: true });
26955
26970
  try {
26956
- execFileSync2("docker", ["cp", `${cid}:/opt/php`, join9(optDir, "php")], { stdio: "inherit" });
26971
+ execFileSync2("docker", ["cp", `${cid}:/opt/php`, join92(optDir, "php")], { stdio: "inherit" });
26957
26972
  } finally {
26958
26973
  execFileSync2("docker", ["rm", cid], { stdio: "ignore" });
26959
26974
  }
26960
- if (!existsSync52(join9(optDir, "php", "bin", "php")))
26975
+ if (!existsSync52(join92(optDir, "php", "bin", "php")))
26961
26976
  throw new Error("layer build produced no /opt/php/bin/php");
26962
26977
  const entries = [];
26963
26978
  for (const file of walk(optDir)) {
@@ -29480,12 +29495,12 @@ var init_dist2 = __esm(async () => {
29480
29495
  docsPaths = ["docs", "documentation", "doc"]
29481
29496
  } = options;
29482
29497
  for (const docsPath of docsPaths) {
29483
- const fullPath = join7(projectRoot, docsPath);
29484
- if (existsSync11(fullPath)) {
29498
+ const fullPath = join9(projectRoot, docsPath);
29499
+ if (existsSync14(fullPath)) {
29485
29500
  const distPaths = ["dist", "build", ".bunpress/dist", "_site", "out", "public"];
29486
29501
  for (const distPath of distPaths) {
29487
- const fullDistPath = join7(fullPath, distPath);
29488
- if (existsSync11(fullDistPath)) {
29502
+ const fullDistPath = join9(fullPath, distPath);
29503
+ if (existsSync14(fullDistPath)) {
29489
29504
  try {
29490
29505
  const files = readdirSync6(fullDistPath);
29491
29506
  if (files.length > 0) {
@@ -29602,16 +29617,16 @@ var init_dist2 = __esm(async () => {
29602
29617
  const privatePath = paths.private || "private";
29603
29618
  return {
29604
29619
  web: {
29605
- exists: existsSync11(join7(projectRoot, webPath)),
29606
- path: join7(projectRoot, webPath)
29620
+ exists: existsSync14(join9(projectRoot, webPath)),
29621
+ path: join9(projectRoot, webPath)
29607
29622
  },
29608
29623
  docs: {
29609
- exists: existsSync11(join7(projectRoot, docsPath)),
29610
- path: join7(projectRoot, docsPath)
29624
+ exists: existsSync14(join9(projectRoot, docsPath)),
29625
+ path: join9(projectRoot, docsPath)
29611
29626
  },
29612
29627
  private: {
29613
- exists: existsSync11(join7(projectRoot, privatePath)),
29614
- path: join7(projectRoot, privatePath)
29628
+ exists: existsSync14(join9(projectRoot, privatePath)),
29629
+ path: join9(projectRoot, privatePath)
29615
29630
  }
29616
29631
  };
29617
29632
  }
@@ -47221,7 +47236,7 @@ __export(exports_client, {
47221
47236
  AWSClient: () => AWSClient
47222
47237
  });
47223
47238
  import * as crypto2 from "node:crypto";
47224
- import { existsSync as existsSync14, readFileSync as readFileSync8 } from "node:fs";
47239
+ import { existsSync as existsSync16, readFileSync as readFileSync8 } from "node:fs";
47225
47240
  import { homedir as homedir6 } from "node:os";
47226
47241
  import { join as join11 } from "node:path";
47227
47242
  function resolveS3Endpoint(options) {
@@ -47282,7 +47297,7 @@ class AWSClient {
47282
47297
  loadCredentialsFromFile() {
47283
47298
  const profile = process.env.AWS_PROFILE || "default";
47284
47299
  const credentialsPath = process.env.AWS_SHARED_CREDENTIALS_FILE || join11(homedir6(), ".aws", "credentials");
47285
- if (!existsSync14(credentialsPath)) {
47300
+ if (!existsSync16(credentialsPath)) {
47286
47301
  return null;
47287
47302
  }
47288
47303
  try {
@@ -47781,7 +47796,7 @@ function detectCredentialSource() {
47781
47796
  }
47782
47797
  const profile = process.env.AWS_PROFILE || "default";
47783
47798
  const credentialsPath = process.env.AWS_SHARED_CREDENTIALS_FILE || join11(homedir6(), ".aws", "credentials");
47784
- if (existsSync14(credentialsPath)) {
47799
+ if (existsSync16(credentialsPath)) {
47785
47800
  return {
47786
47801
  source: "file",
47787
47802
  profile,
@@ -50354,8 +50369,8 @@ class CloudFrontClient {
50354
50369
  returnHeaders: true
50355
50370
  });
50356
50371
  const etag = getResult.headers?.etag || getResult.headers?.ETag || "";
50357
- const currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig;
50358
- if (!currentConfig) {
50372
+ const currentConfig = getResult.body?.DistributionConfig || (getResult.body?.Enabled !== undefined ? getResult.body : undefined) || getResult.DistributionConfig;
50373
+ if (!currentConfig || currentConfig.Enabled === undefined) {
50359
50374
  throw new Error("Failed to get current distribution config");
50360
50375
  }
50361
50376
  if (customErrorResponses.length === 0) {
@@ -50416,8 +50431,8 @@ class CloudFrontClient {
50416
50431
  returnHeaders: true
50417
50432
  });
50418
50433
  const etag = getResult.headers?.etag || getResult.headers?.ETag || "";
50419
- const currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig;
50420
- if (!currentConfig) {
50434
+ const currentConfig = getResult.body?.DistributionConfig || (getResult.body?.Enabled !== undefined ? getResult.body : undefined) || getResult.DistributionConfig;
50435
+ if (!currentConfig || currentConfig.Enabled === undefined) {
50421
50436
  throw new Error("Failed to get current distribution config");
50422
50437
  }
50423
50438
  if (aliases && aliases.length > 0) {
@@ -51064,8 +51079,8 @@ ${Object.entries(config6).filter(([k]) => !k.startsWith("@_")).map(([key, val])
51064
51079
  returnHeaders: true
51065
51080
  });
51066
51081
  const etag = getResult.headers?.etag || getResult.headers?.ETag || "";
51067
- const currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig;
51068
- if (!currentConfig) {
51082
+ const currentConfig = getResult.body?.DistributionConfig || (getResult.body?.Enabled !== undefined ? getResult.body : undefined) || getResult.DistributionConfig;
51083
+ if (!currentConfig || currentConfig.Enabled === undefined) {
51069
51084
  throw new Error("Failed to get current distribution config");
51070
51085
  }
51071
51086
  currentConfig.Enabled = false;
@@ -51125,8 +51140,8 @@ ${Object.entries(config6).filter(([k]) => !k.startsWith("@_")).map(([key, val])
51125
51140
  returnHeaders: true
51126
51141
  });
51127
51142
  const etag = getResult.headers?.etag || getResult.headers?.ETag || "";
51128
- const currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig;
51129
- if (!currentConfig) {
51143
+ const currentConfig = getResult.body?.DistributionConfig || (getResult.body?.Enabled !== undefined ? getResult.body : undefined) || getResult.DistributionConfig;
51144
+ if (!currentConfig || currentConfig.Enabled === undefined) {
51130
51145
  throw new Error("Failed to get current distribution config");
51131
51146
  }
51132
51147
  let items = [];
@@ -58116,14 +58131,14 @@ __export(exports_serverless_image, {
58116
58131
  buildAndPushServerlessImage: () => buildAndPushServerlessImage
58117
58132
  });
58118
58133
  import { execFileSync as execFileSync3 } from "node:child_process";
58119
- import { mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync4, rmSync as rmSync4, writeFileSync as writeFileSync10 } from "node:fs";
58134
+ import { mkdirSync as mkdirSync8, mkdtempSync as mkdtempSync4, rmSync as rmSync6, writeFileSync as writeFileSync11 } from "node:fs";
58120
58135
  import { tmpdir as tmpdir4 } from "node:os";
58121
- import { dirname as dirname7, join as join15 } from "node:path";
58136
+ import { dirname as dirname7, join as join16 } from "node:path";
58122
58137
  function writeEntries(baseDir, entries) {
58123
58138
  for (const e of entries) {
58124
- const dest = join15(baseDir, e.name);
58125
- mkdirSync7(dirname7(dest), { recursive: true });
58126
- writeFileSync10(dest, e.data, e.mode ? { mode: e.mode } : undefined);
58139
+ const dest = join16(baseDir, e.name);
58140
+ mkdirSync8(dirname7(dest), { recursive: true });
58141
+ writeFileSync11(dest, e.data, e.mode ? { mode: e.mode } : undefined);
58127
58142
  }
58128
58143
  }
58129
58144
  async function buildAndPushServerlessImage(opts) {
@@ -58132,10 +58147,10 @@ async function buildAndPushServerlessImage(opts) {
58132
58147
  const platform = arch === "arm64" ? "linux/arm64" : "linux/amd64";
58133
58148
  const isPhp = app.kind === "php";
58134
58149
  const step2 = opts.onStep ?? (() => {});
58135
- const stage = mkdtempSync4(join15(tmpdir4(), "tscloud-img-"));
58150
+ const stage = mkdtempSync4(join16(tmpdir4(), "tscloud-img-"));
58136
58151
  try {
58137
- const appDir = join15(stage, "app");
58138
- mkdirSync7(appDir, { recursive: true });
58152
+ const appDir = join16(stage, "app");
58153
+ mkdirSync8(appDir, { recursive: true });
58139
58154
  let handlers;
58140
58155
  let contentHash;
58141
58156
  if (isPhp) {
@@ -58143,19 +58158,19 @@ async function buildAndPushServerlessImage(opts) {
58143
58158
  step2("staging application tree");
58144
58159
  const entries = collectPhpAppEntries(projectRoot);
58145
58160
  writeEntries(appDir, entries);
58146
- writeEntries(join15(stage, "runtime"), phpRuntimeLayerAssets().map((a) => ({ name: a.path, data: a.contents, mode: a.mode })));
58161
+ writeEntries(join16(stage, "runtime"), phpRuntimeLayerAssets().map((a) => ({ name: a.path, data: a.contents, mode: a.mode })));
58147
58162
  const handler8 = app.handlers?.http ?? "public/index.php";
58148
58163
  handlers = { http: handler8, queue: app.handlers?.queue ?? handler8, cli: app.handlers?.cli ?? handler8 };
58149
58164
  contentHash = sha256(entries.map((e) => `${e.name}:${e.data.length}`).join("|"));
58150
58165
  } else {
58151
58166
  step2("bundling application");
58152
58167
  const artifact = await packageServerlessApp({ projectRoot, app, skipBuild: opts.skipBuild, onStep: step2 });
58153
- writeFileSync10(join15(appDir, "index.mjs"), artifact.bundle);
58168
+ writeFileSync11(join16(appDir, "index.mjs"), artifact.bundle);
58154
58169
  handlers = artifact.handlers;
58155
58170
  contentHash = artifact.sha256;
58156
58171
  }
58157
58172
  const tag = contentHash.slice(0, 16);
58158
- writeFileSync10(join15(stage, "Dockerfile"), generateAppImageDockerfile({
58173
+ writeFileSync11(join16(stage, "Dockerfile"), generateAppImageDockerfile({
58159
58174
  kind: app.kind ?? "node",
58160
58175
  phpVersion: app.phpVersion,
58161
58176
  architecture: arch
@@ -58184,14 +58199,14 @@ async function buildAndPushServerlessImage(opts) {
58184
58199
  if (!data?.authorizationToken || !data.proxyEndpoint)
58185
58200
  throw new Error("failed to get an ECR authorization token");
58186
58201
  const registry = data.proxyEndpoint.replace(/^https?:\/\//, "");
58187
- const dockerConfig = join15(stage, ".docker");
58188
- mkdirSync7(dockerConfig, { recursive: true });
58189
- writeFileSync10(join15(dockerConfig, "config.json"), JSON.stringify({ auths: { [registry]: { auth: data.authorizationToken } } }));
58202
+ const dockerConfig = join16(stage, ".docker");
58203
+ mkdirSync8(dockerConfig, { recursive: true });
58204
+ writeFileSync11(join16(dockerConfig, "config.json"), JSON.stringify({ auths: { [registry]: { auth: data.authorizationToken } } }));
58190
58205
  execFileSync3("docker", ["push", imageUri], { stdio: "inherit", env: { ...process.env, DOCKER_CONFIG: dockerConfig } });
58191
58206
  info(`Image: ${imageUri}`);
58192
58207
  return { imageUri, tag, handlers };
58193
58208
  } finally {
58194
- rmSync4(stage, { recursive: true, force: true });
58209
+ rmSync6(stage, { recursive: true, force: true });
58195
58210
  }
58196
58211
  }
58197
58212
  var init_serverless_image = __esm(async () => {
@@ -58200,6 +58215,11 @@ var init_serverless_image = __esm(async () => {
58200
58215
  await init_dist2();
58201
58216
  });
58202
58217
 
58218
+ // src/config.ts
58219
+ import { existsSync as existsSync11 } from "node:fs";
58220
+ import { join as join7 } from "node:path";
58221
+ import { pathToFileURL } from "node:url";
58222
+
58203
58223
  // ../../node_modules/bunfig/dist/index.js
58204
58224
  import { existsSync, statSync } from "fs";
58205
58225
  import { existsSync as existsSync13, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync, writeFileSync as writeFileSync7 } from "fs";
@@ -66891,12 +66911,33 @@ var defaultConfig4 = {
66891
66911
  }
66892
66912
  };
66893
66913
  var _config = null;
66914
+ function isDefaultConfig(config5) {
66915
+ return config5.project?.slug === defaultConfig4.project?.slug && Object.keys(config5.environments ?? {}).length === 1 && !!config5.environments?.production;
66916
+ }
66917
+ async function loadStacksCloudConfig() {
66918
+ const candidates = [
66919
+ join7(process.cwd(), "config", "cloud.ts"),
66920
+ join7(process.cwd(), "config", "cloud.js")
66921
+ ];
66922
+ for (const file of candidates) {
66923
+ if (!existsSync11(file))
66924
+ continue;
66925
+ const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
66926
+ const config5 = mod.tsCloud ?? mod.default?.tsCloud ?? mod.default ?? mod.cloud ?? mod.config;
66927
+ if (config5)
66928
+ return config5;
66929
+ }
66930
+ return null;
66931
+ }
66894
66932
  async function getConfig() {
66895
66933
  if (!_config) {
66896
66934
  _config = await loadConfig6({
66897
66935
  name: "cloud",
66898
66936
  defaultConfig: defaultConfig4
66899
66937
  });
66938
+ if (isDefaultConfig(_config)) {
66939
+ _config = await loadStacksCloudConfig() ?? _config;
66940
+ }
66900
66941
  }
66901
66942
  return _config;
66902
66943
  }
@@ -70411,9 +70452,143 @@ init_cloudformation();
70411
70452
  init_client();
70412
70453
 
70413
70454
  // src/aws/cost-explorer-cache.ts
70455
+ import { createHash as createHash9 } from "node:crypto";
70456
+ import { existsSync as existsSync17, mkdirSync as mkdirSync7, readdirSync as readdirSync8, readFileSync as readFileSync11, rmSync as rmSync4, writeFileSync as writeFileSync10 } from "node:fs";
70457
+ import { homedir as homedir7 } from "node:os";
70458
+ import { join as join14 } from "node:path";
70459
+ var CACHE_SCHEMA_VERSION = 1;
70414
70460
  var HOUR_MS = 60 * 60 * 1000;
70415
70461
  var DAY_MS = 24 * HOUR_MS;
70462
+ var OPEN_PERIOD_TTL_MS = HOUR_MS;
70416
70463
  var CLOSED_PERIOD_TTL_MS = 30 * DAY_MS;
70464
+ function cacheRoot() {
70465
+ const xdg = process.env.XDG_CACHE_HOME;
70466
+ return join14(xdg && xdg.trim() ? xdg : join14(homedir7(), ".cache"), "ts-cloud", "cost-explorer");
70467
+ }
70468
+ function profileDir(profile) {
70469
+ return join14(cacheRoot(), profile ?? "__default__");
70470
+ }
70471
+ function hashKey(key) {
70472
+ const canonical = {
70473
+ s: key.start,
70474
+ e: key.end,
70475
+ g: key.granularity,
70476
+ m: [...key.metrics].sort(),
70477
+ gb: [...key.groupBy].map((g) => `${g.Type}:${g.Key}`).sort()
70478
+ };
70479
+ return createHash9("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 24);
70480
+ }
70481
+ function isClosedPeriod(start, end) {
70482
+ const endTs = Date.parse(`${end}T00:00:00Z`);
70483
+ if (Number.isNaN(endTs))
70484
+ return false;
70485
+ const now = new Date;
70486
+ const firstOfThisMonth = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
70487
+ return endTs <= firstOfThisMonth;
70488
+ }
70489
+ function ttlForPeriod(key) {
70490
+ return isClosedPeriod(key.start, key.end) ? CLOSED_PERIOD_TTL_MS : OPEN_PERIOD_TTL_MS;
70491
+ }
70492
+ function loadCache(profile, key) {
70493
+ const file = join14(profileDir(profile), `${hashKey(key)}.json`);
70494
+ if (!existsSync17(file))
70495
+ return null;
70496
+ let entry;
70497
+ try {
70498
+ entry = JSON.parse(readFileSync11(file, "utf-8"));
70499
+ } catch {
70500
+ return null;
70501
+ }
70502
+ if (entry.schema !== CACHE_SCHEMA_VERSION)
70503
+ return null;
70504
+ const ageMs = Date.now() - entry.savedAt;
70505
+ if (ageMs < 0 || ageMs > ttlForPeriod(key))
70506
+ return null;
70507
+ return { response: entry.response, ageSeconds: Math.floor(ageMs / 1000) };
70508
+ }
70509
+ function saveCache(profile, key, response) {
70510
+ const dir = profileDir(profile);
70511
+ mkdirSync7(dir, { recursive: true });
70512
+ const entry = {
70513
+ schema: CACHE_SCHEMA_VERSION,
70514
+ savedAt: Date.now(),
70515
+ key,
70516
+ response
70517
+ };
70518
+ writeFileSync10(join14(dir, `${hashKey(key)}.json`), JSON.stringify(entry));
70519
+ }
70520
+
70521
+ // src/aws/cost-explorer.ts
70522
+ class CostExplorerClient {
70523
+ client;
70524
+ profile;
70525
+ useCache;
70526
+ lastCacheAgeSeconds = null;
70527
+ constructor(profile, options) {
70528
+ this.profile = profile;
70529
+ this.useCache = options?.useCache ?? true;
70530
+ this.client = new AWSClient(resolveCredentials2(profile));
70531
+ }
70532
+ async getCostByService(params) {
70533
+ const { start, end, granularity = "MONTHLY" } = params;
70534
+ const metrics = ["UnblendedCost"];
70535
+ const groupBy = [{ Type: "DIMENSION", Key: "SERVICE" }];
70536
+ const cacheKey = { start, end, granularity, metrics, groupBy };
70537
+ if (this.useCache) {
70538
+ const hit = loadCache(this.profile, cacheKey);
70539
+ if (hit) {
70540
+ this.lastCacheAgeSeconds = hit.ageSeconds;
70541
+ return hit.response;
70542
+ }
70543
+ }
70544
+ this.lastCacheAgeSeconds = null;
70545
+ const result = await this.client.request({
70546
+ service: "ce",
70547
+ region: "us-east-1",
70548
+ method: "POST",
70549
+ path: "/",
70550
+ headers: {
70551
+ "content-type": "application/x-amz-json-1.1",
70552
+ "x-amz-target": "AWSInsightsIndexService.GetCostAndUsage"
70553
+ },
70554
+ body: JSON.stringify({
70555
+ TimePeriod: { Start: start, End: end },
70556
+ Granularity: granularity,
70557
+ Metrics: metrics,
70558
+ GroupBy: groupBy
70559
+ })
70560
+ });
70561
+ const groups = result?.ResultsByTime?.[0]?.Groups ?? [];
70562
+ const services = groups.map((g) => ({
70563
+ service: g.Keys?.[0] ?? "Unknown",
70564
+ amount: Number.parseFloat(g.Metrics?.UnblendedCost?.Amount ?? "0"),
70565
+ unit: g.Metrics?.UnblendedCost?.Unit ?? "USD"
70566
+ })).filter((g) => g.amount > 0).sort((a, b) => b.amount - a.amount);
70567
+ if (this.useCache) {
70568
+ saveCache(this.profile, cacheKey, services);
70569
+ }
70570
+ return services;
70571
+ }
70572
+ async getDailyTotals(params) {
70573
+ const result = await this.client.request({
70574
+ service: "ce",
70575
+ region: "us-east-1",
70576
+ method: "POST",
70577
+ path: "/",
70578
+ headers: {
70579
+ "content-type": "application/x-amz-json-1.1",
70580
+ "x-amz-target": "AWSInsightsIndexService.GetCostAndUsage"
70581
+ },
70582
+ body: JSON.stringify({
70583
+ TimePeriod: { Start: params.start, End: params.end },
70584
+ Granularity: "DAILY",
70585
+ Metrics: ["UnblendedCost"]
70586
+ })
70587
+ });
70588
+ const byTime = result?.ResultsByTime ?? [];
70589
+ return byTime.map((t) => Number.parseFloat(t.Total?.UnblendedCost?.Amount ?? "0"));
70590
+ }
70591
+ }
70417
70592
  // src/aws/ec2.ts
70418
70593
  init_client();
70419
70594
 
@@ -78946,7 +79121,7 @@ class ApplicationAutoScalingClient {
78946
79121
  init_s3();
78947
79122
  import * as net from "node:net";
78948
79123
  import * as tls from "node:tls";
78949
- import { readFileSync as readFileSync11 } from "node:fs";
79124
+ import { readFileSync as readFileSync12 } from "node:fs";
78950
79125
  import * as crypto4 from "node:crypto";
78951
79126
 
78952
79127
  class ImapServer {
@@ -78981,8 +79156,8 @@ class ImapServer {
78981
79156
  });
78982
79157
  if (this.config.tls?.key && this.config.tls?.cert) {
78983
79158
  const tlsOptions = {
78984
- key: readFileSync11(this.config.tls.key),
78985
- cert: readFileSync11(this.config.tls.cert)
79159
+ key: readFileSync12(this.config.tls.key),
79160
+ cert: readFileSync12(this.config.tls.cert)
78986
79161
  };
78987
79162
  this.tlsServer = tls.createServer(tlsOptions, (socket) => {
78988
79163
  this.handleConnection(socket);
@@ -79786,8 +79961,8 @@ class ImapServer {
79786
79961
  }
79787
79962
  this.send(session, `${tag} OK Begin TLS negotiation`);
79788
79963
  const tlsOptions = {
79789
- key: readFileSync11(this.config.tls.key),
79790
- cert: readFileSync11(this.config.tls.cert),
79964
+ key: readFileSync12(this.config.tls.key),
79965
+ cert: readFileSync12(this.config.tls.cert),
79791
79966
  isServer: true
79792
79967
  };
79793
79968
  const tlsSocket = new tls.TLSSocket(session.socket, tlsOptions);
@@ -80380,7 +80555,7 @@ init_ses();
80380
80555
  init_s3();
80381
80556
  import * as net2 from "node:net";
80382
80557
  import * as tls2 from "node:tls";
80383
- import { readFileSync as readFileSync12 } from "node:fs";
80558
+ import { readFileSync as readFileSync13 } from "node:fs";
80384
80559
  import * as crypto5 from "node:crypto";
80385
80560
 
80386
80561
  class SmtpServer {
@@ -80411,8 +80586,8 @@ class SmtpServer {
80411
80586
  });
80412
80587
  if (this.config.tls?.key && this.config.tls?.cert) {
80413
80588
  const tlsOptions = {
80414
- key: readFileSync12(this.config.tls.key),
80415
- cert: readFileSync12(this.config.tls.cert)
80589
+ key: readFileSync13(this.config.tls.key),
80590
+ cert: readFileSync13(this.config.tls.cert)
80416
80591
  };
80417
80592
  this.tlsServer = tls2.createServer(tlsOptions, (socket) => {
80418
80593
  this.handleConnection(socket, true);
@@ -80546,8 +80721,8 @@ class SmtpServer {
80546
80721
  }
80547
80722
  this.send(session, "220 Ready to start TLS");
80548
80723
  const tlsOptions = {
80549
- key: readFileSync12(this.config.tls.key),
80550
- cert: readFileSync12(this.config.tls.cert),
80724
+ key: readFileSync13(this.config.tls.key),
80725
+ cert: readFileSync13(this.config.tls.cert),
80551
80726
  isServer: true
80552
80727
  };
80553
80728
  const tlsSocket = new tls2.TLSSocket(session.socket, tlsOptions);
@@ -82896,7 +83071,7 @@ function needsRenewal(certPath) {
82896
83071
  }
82897
83072
  }
82898
83073
  // src/ssl/acme-client.ts
82899
- import { createHash as createHash9, createSign, generateKeyPairSync } from "node:crypto";
83074
+ import { createHash as createHash10, createSign, generateKeyPairSync } from "node:crypto";
82900
83075
  var ACME_DIRECTORIES = {
82901
83076
  production: "https://acme-v02.api.letsencrypt.org/directory",
82902
83077
  staging: "https://acme-staging-v02.api.letsencrypt.org/directory"
@@ -82966,7 +83141,7 @@ class AcmeClient {
82966
83141
  x: jwk.x,
82967
83142
  y: jwk.y
82968
83143
  });
82969
- const hash2 = createHash9("sha256").update(canonical).digest();
83144
+ const hash2 = createHash10("sha256").update(canonical).digest();
82970
83145
  return this.base64UrlEncode(hash2);
82971
83146
  }
82972
83147
  base64UrlEncode(data) {
@@ -83079,7 +83254,7 @@ class AcmeClient {
83079
83254
  identifier: `/.well-known/acme-challenge/${c.token}`
83080
83255
  };
83081
83256
  } else {
83082
- const dnsValue = this.base64UrlEncode(createHash9("sha256").update(keyAuthorization).digest());
83257
+ const dnsValue = this.base64UrlEncode(createHash10("sha256").update(keyAuthorization).digest());
83083
83258
  return {
83084
83259
  type: "dns-01",
83085
83260
  token: c.token,
@@ -83167,6 +83342,8 @@ function resolveSiteDeployTarget(site) {
83167
83342
  return "bucket";
83168
83343
  }
83169
83344
  function resolveSiteKind(site) {
83345
+ if (site.redirect)
83346
+ return "redirect";
83170
83347
  if (isPhpSite(site))
83171
83348
  return "server-php";
83172
83349
  const target = resolveSiteDeployTarget(site);
@@ -83189,6 +83366,24 @@ function validateDeploymentConfig(config6) {
83189
83366
  }
83190
83367
  const target = resolveSiteDeployTarget(site);
83191
83368
  const kind = resolveSiteKind(site);
83369
+ if (kind === "redirect") {
83370
+ if (!site.domain)
83371
+ errors.push(`Site '${name}' is a redirect site but has no \`domain\` to redirect from.`);
83372
+ const to = typeof site.redirect === "string" ? site.redirect : site.redirect?.to;
83373
+ if (!to)
83374
+ errors.push(`Site '${name}' is a redirect site but has no redirect target (\`redirect\` / \`redirect.to\`).`);
83375
+ if (!computeConfigured) {
83376
+ errors.push(`Site '${name}' is a redirect site but no \`infrastructure.compute\` is configured to host the gateway that serves the redirect.`);
83377
+ }
83378
+ const serverOnly = [];
83379
+ if (site.start)
83380
+ serverOnly.push("start");
83381
+ if (site.root)
83382
+ serverOnly.push("root");
83383
+ if (serverOnly.length > 0)
83384
+ warnings.push(`Site '${name}' is a redirect site but also sets ${serverOnly.join(", ")}. These are ignored.`);
83385
+ continue;
83386
+ }
83192
83387
  if (target === "server" && !site.start && !site.root) {
83193
83388
  errors.push(`Site '${name}' sets deploy:'server' but declares neither \`start\` (dynamic app) nor \`root\` (static site to serve). Add one.`);
83194
83389
  continue;
@@ -83245,12 +83440,12 @@ init_static_site_external_dns();
83245
83440
  // src/deploy/static-site-helper.ts
83246
83441
  init_static_site_external_dns();
83247
83442
  import process19 from "node:process";
83248
- import { existsSync as existsSync16 } from "node:fs";
83249
- import { join as join14 } from "node:path";
83443
+ import { existsSync as existsSync18 } from "node:fs";
83444
+ import { join as join15 } from "node:path";
83250
83445
  async function deploySite(config6) {
83251
83446
  const start = Date.now();
83252
83447
  const sourceDir = config6.sourceDir ?? "dist";
83253
- if (!existsSync16(join14(sourceDir, "index.html"))) {
83448
+ if (!existsSync18(join15(sourceDir, "index.html"))) {
83254
83449
  return fail(start, `${sourceDir}/ has no index.html — build first.`);
83255
83450
  }
83256
83451
  if (!process19.env.AWS_ACCESS_KEY_ID || !process19.env.AWS_SECRET_ACCESS_KEY) {
@@ -83321,8 +83516,8 @@ function fail(start, message) {
83321
83516
  // src/deploy/serverless-app.ts
83322
83517
  init_cloudformation();
83323
83518
  await init_dist2();
83324
- import { existsSync as existsSync17, readdirSync as readdirSync8, readFileSync as readFileSync13, statSync as statSync5 } from "node:fs";
83325
- import { join as join16, relative as relative6 } from "node:path";
83519
+ import { existsSync as existsSync19, readdirSync as readdirSync9, readFileSync as readFileSync14, statSync as statSync5 } from "node:fs";
83520
+ import { join as join17, relative as relative6 } from "node:path";
83326
83521
  init_s3();
83327
83522
  init_cli();
83328
83523
  function resolveContext(config6, environment) {
@@ -83461,8 +83656,8 @@ function contentType(file) {
83461
83656
  return map[ext] ?? "application/octet-stream";
83462
83657
  }
83463
83658
  function* walk3(dir) {
83464
- for (const entry of readdirSync8(dir)) {
83465
- const full = join16(dir, entry);
83659
+ for (const entry of readdirSync9(dir)) {
83660
+ const full = join17(dir, entry);
83466
83661
  if (statSync5(full).isDirectory())
83467
83662
  yield* walk3(full);
83468
83663
  else
@@ -83478,7 +83673,7 @@ async function uploadAssets(s32, bucket, dir, prefix, includeDotfiles = false) {
83478
83673
  await s32.putObject({
83479
83674
  bucket,
83480
83675
  key: `${prefix}/${rel}`,
83481
- body: readFileSync13(file),
83676
+ body: readFileSync14(file),
83482
83677
  contentType: contentType(file),
83483
83678
  cacheControl: "public, max-age=31536000, immutable"
83484
83679
  });
@@ -83610,8 +83805,8 @@ async function deployServerlessApp(config6, environment, opts = {}) {
83610
83805
  const outputs = await cfn.getStackOutputs(stackName).catch(() => ({}));
83611
83806
  let assetUrl;
83612
83807
  if (app.assets) {
83613
- const assetsDir3 = join16(projectRoot, app.assets);
83614
- if (existsSync17(assetsDir3)) {
83808
+ const assetsDir3 = join17(projectRoot, app.assets);
83809
+ if (existsSync19(assetsDir3)) {
83615
83810
  const cdn = app.assetDomain || outputs.AssetsCdnDomain;
83616
83811
  assetUrl = cdn ? `https://${cdn}/${artifactSha}` : undefined;
83617
83812
  step(`Syncing assets from ${app.assets}`);
@@ -83791,6 +83986,54 @@ async function runRemoteCommand(config6, environment, command) {
83791
83986
  throw new Error(`Command failed: ${res.Payload}`);
83792
83987
  return res.Payload ?? "";
83793
83988
  }
83989
+ async function serverlessInfo(config6, environment) {
83990
+ const ctx = resolveContext(config6, environment);
83991
+ const cf = new CloudFormationClient(ctx.region);
83992
+ const lambda2 = new LambdaClient(ctx.region);
83993
+ const s32 = new S3Client2(ctx.region);
83994
+ let stackStatus = "not deployed";
83995
+ const outputs = {};
83996
+ try {
83997
+ const { Stacks } = await cf.describeStacks({ stackName: ctx.stackName });
83998
+ stackStatus = Stacks?.[0]?.StackStatus ?? stackStatus;
83999
+ for (const o of Stacks?.[0]?.Outputs ?? [])
84000
+ if (o.OutputKey)
84001
+ outputs[o.OutputKey] = o.OutputValue ?? "";
84002
+ } catch {}
84003
+ const queues = resolveQueueNames(ctx.app, ctx.slug, environment);
84004
+ const pc = ctx.app.provisionedConcurrency ?? 0;
84005
+ const modes = ["http", "cli", ...queues.length ? ["queue"] : []];
84006
+ const base = `${ctx.slug}-${environment}`;
84007
+ const functions = [];
84008
+ for (const mode of modes) {
84009
+ const name = `${base}-${mode}`;
84010
+ const info2 = { mode, name, version: "$LATEST" };
84011
+ if (pc > 0) {
84012
+ const alias = await lambda2.getAlias(name, "live").catch(() => null);
84013
+ if (alias?.FunctionVersion) {
84014
+ info2.version = alias.FunctionVersion;
84015
+ const pcc = await lambda2.getProvisionedConcurrencyConfig(name, "live").catch(() => null);
84016
+ if (pcc)
84017
+ info2.provisioned = { status: pcc.Status ?? "-", allocated: pcc.AllocatedProvisionedConcurrentExecutions ?? 0, requested: pcc.RequestedProvisionedConcurrentExecutions ?? 0 };
84018
+ }
84019
+ }
84020
+ functions.push(info2);
84021
+ }
84022
+ const release = await readRelease(s32, ctx.artifactBucket, ctx.slug, environment).catch(() => null);
84023
+ return {
84024
+ slug: ctx.slug,
84025
+ environment,
84026
+ region: ctx.region,
84027
+ stackStatus,
84028
+ endpoint: outputs.HttpApiEndpoint,
84029
+ assetUrl: release?.assetUrl,
84030
+ scheduler: ctx.app.scheduler ?? "on",
84031
+ queues,
84032
+ provisionedConcurrency: pc,
84033
+ functions,
84034
+ lastRelease: release ? { sha: release.sha, timestamp: release.timestamp } : undefined
84035
+ };
84036
+ }
83794
84037
 
83795
84038
  // src/deploy/index.ts
83796
84039
  await init_serverless_image();
@@ -83802,18 +84045,496 @@ await init_dist2();
83802
84045
  // src/aws/cloudwatch.ts
83803
84046
  init_client();
83804
84047
 
84048
+ class CloudWatchClient {
84049
+ client;
84050
+ region;
84051
+ constructor(region = "us-east-1") {
84052
+ this.region = region;
84053
+ this.client = new AWSClient;
84054
+ }
84055
+ async query(params) {
84056
+ const body = new URLSearchParams(buildQueryParams({ Version: "2010-08-01", ...params })).toString();
84057
+ return this.client.request({
84058
+ service: "monitoring",
84059
+ region: this.region,
84060
+ method: "POST",
84061
+ path: "/",
84062
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
84063
+ body
84064
+ });
84065
+ }
84066
+ async getMetricStatistics(options) {
84067
+ const params = {
84068
+ Action: "GetMetricStatistics",
84069
+ Namespace: options.Namespace,
84070
+ MetricName: options.MetricName,
84071
+ StartTime: options.StartTime.toISOString(),
84072
+ EndTime: options.EndTime.toISOString(),
84073
+ Period: options.Period
84074
+ };
84075
+ (options.Statistics ?? []).forEach((s, i) => {
84076
+ params[`Statistics.member.${i + 1}`] = s;
84077
+ });
84078
+ (options.ExtendedStatistics ?? []).forEach((s, i) => {
84079
+ params[`ExtendedStatistics.member.${i + 1}`] = s;
84080
+ });
84081
+ (options.Dimensions ?? []).forEach((d, i) => {
84082
+ params[`Dimensions.member.${i + 1}.Name`] = d.Name;
84083
+ params[`Dimensions.member.${i + 1}.Value`] = d.Value;
84084
+ });
84085
+ const result = await this.query(params);
84086
+ const response = result.GetMetricStatisticsResult || result;
84087
+ let points = response?.Datapoints?.member ?? [];
84088
+ if (!Array.isArray(points))
84089
+ points = points ? [points] : [];
84090
+ return points.map((p) => {
84091
+ const percentiles = {};
84092
+ let entries = p.ExtendedStatistics?.entry;
84093
+ if (entries) {
84094
+ if (!Array.isArray(entries))
84095
+ entries = [entries];
84096
+ for (const e of entries)
84097
+ if (e?.key != null)
84098
+ percentiles[e.key] = Number(e.value);
84099
+ }
84100
+ return {
84101
+ Timestamp: p.Timestamp,
84102
+ Sum: p.Sum != null ? Number(p.Sum) : undefined,
84103
+ Average: p.Average != null ? Number(p.Average) : undefined,
84104
+ Maximum: p.Maximum != null ? Number(p.Maximum) : undefined,
84105
+ Minimum: p.Minimum != null ? Number(p.Minimum) : undefined,
84106
+ Percentiles: Object.keys(percentiles).length ? percentiles : undefined,
84107
+ Unit: p.Unit
84108
+ };
84109
+ });
84110
+ }
84111
+ async getMetricSeries(options) {
84112
+ const isPct = /^p\d/.test(options.Stat);
84113
+ const pts = await this.getMetricStatistics({
84114
+ Namespace: options.Namespace,
84115
+ MetricName: options.MetricName,
84116
+ Dimensions: options.Dimensions,
84117
+ StartTime: options.StartTime,
84118
+ EndTime: options.EndTime,
84119
+ Period: options.Period,
84120
+ Statistics: isPct ? [] : [options.Stat],
84121
+ ExtendedStatistics: isPct ? [options.Stat] : []
84122
+ });
84123
+ return pts.slice().sort((a, b) => new Date(a.Timestamp ?? 0).getTime() - new Date(b.Timestamp ?? 0).getTime()).map((p) => isPct ? p.Percentiles?.[options.Stat] ?? 0 : p[options.Stat] ?? 0);
84124
+ }
84125
+ async describeAlarms(options) {
84126
+ const params = { Action: "DescribeAlarms" };
84127
+ if (options?.AlarmNamePrefix)
84128
+ params.AlarmNamePrefix = options.AlarmNamePrefix;
84129
+ if (options?.MaxRecords)
84130
+ params.MaxRecords = options.MaxRecords;
84131
+ const result = await this.query(params);
84132
+ const response = result.DescribeAlarmsResult || result;
84133
+ let alarms = response?.MetricAlarms?.member ?? [];
84134
+ if (!Array.isArray(alarms))
84135
+ alarms = alarms ? [alarms] : [];
84136
+ return alarms.map((a) => ({
84137
+ AlarmName: a.AlarmName,
84138
+ StateValue: a.StateValue,
84139
+ MetricName: a.MetricName,
84140
+ ComparisonOperator: a.ComparisonOperator,
84141
+ Threshold: a.Threshold != null ? Number(a.Threshold) : undefined,
84142
+ Namespace: a.Namespace
84143
+ }));
84144
+ }
84145
+ async putMetricAlarm(options) {
84146
+ const params = {
84147
+ Action: "PutMetricAlarm",
84148
+ AlarmName: options.AlarmName,
84149
+ Namespace: options.Namespace,
84150
+ MetricName: options.MetricName,
84151
+ ComparisonOperator: options.ComparisonOperator,
84152
+ Threshold: options.Threshold,
84153
+ EvaluationPeriods: options.EvaluationPeriods,
84154
+ Period: options.Period,
84155
+ Statistic: options.Statistic
84156
+ };
84157
+ if (options.AlarmDescription)
84158
+ params.AlarmDescription = options.AlarmDescription;
84159
+ (options.Dimensions ?? []).forEach((d, i) => {
84160
+ params[`Dimensions.member.${i + 1}.Name`] = d.Name;
84161
+ params[`Dimensions.member.${i + 1}.Value`] = d.Value;
84162
+ });
84163
+ (options.AlarmActions ?? []).forEach((a, i) => {
84164
+ params[`AlarmActions.member.${i + 1}`] = a;
84165
+ });
84166
+ await this.query(params);
84167
+ }
84168
+ }
84169
+
83805
84170
  // src/deploy/dashboard-data.ts
83806
84171
  init_s3();
83807
84172
 
83808
84173
  // src/aws/wafv2.ts
83809
84174
  init_client();
84175
+
84176
+ class WAFv2Client {
84177
+ client;
84178
+ region;
84179
+ constructor(region = "us-east-1") {
84180
+ this.region = region;
84181
+ this.client = new AWSClient;
84182
+ }
84183
+ async call(target, body) {
84184
+ return this.client.request({
84185
+ service: "wafv2",
84186
+ region: this.region,
84187
+ method: "POST",
84188
+ path: "/",
84189
+ headers: { "Content-Type": "application/x-amz-json-1.1", "X-Amz-Target": `AWSWAF_20190729.${target}` },
84190
+ body: JSON.stringify(body)
84191
+ });
84192
+ }
84193
+ async listWebACLs(scope = "REGIONAL") {
84194
+ const r = await this.call("ListWebACLs", { Scope: scope, Limit: 100 });
84195
+ return r?.WebACLs ?? [];
84196
+ }
84197
+ async getWebACLRules(name, id, scope = "REGIONAL") {
84198
+ const r = await this.call("GetWebACL", { Name: name, Id: id, Scope: scope });
84199
+ const rules = r?.WebACL?.Rules ?? [];
84200
+ return rules.map((rule) => ({
84201
+ Name: rule.Name,
84202
+ Priority: rule.Priority,
84203
+ Action: rule.Action ? Object.keys(rule.Action)[0] : rule.OverrideAction ? Object.keys(rule.OverrideAction)[0] : "count"
84204
+ }));
84205
+ }
84206
+ }
84207
+
84208
+ // src/deploy/dashboard-data.ts
84209
+ function pad(n) {
84210
+ return String(n).padStart(2, "0");
84211
+ }
84212
+ function isoDate(d) {
84213
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
84214
+ }
84215
+ function relativeLogTime(ms) {
84216
+ const seconds = Math.max(0, Math.round((Date.now() - ms) / 1000));
84217
+ if (seconds < 60)
84218
+ return `${seconds}s ago`;
84219
+ const minutes = Math.round(seconds / 60);
84220
+ if (minutes < 60)
84221
+ return `${minutes}m ago`;
84222
+ const hours = Math.round(minutes / 60);
84223
+ if (hours < 48)
84224
+ return `${hours}h ago`;
84225
+ return `${Math.round(hours / 24)}d ago`;
84226
+ }
84227
+ async function lambdaMetric(cw, fn, name, kind, start, end) {
84228
+ const pts = await cw.getMetricStatistics({
84229
+ Namespace: "AWS/Lambda",
84230
+ MetricName: name,
84231
+ Dimensions: [{ Name: "FunctionName", Value: fn }],
84232
+ StartTime: start,
84233
+ EndTime: end,
84234
+ Period: 86400,
84235
+ Statistics: [kind]
84236
+ }).catch(() => []);
84237
+ if (!pts.length)
84238
+ return 0;
84239
+ if (kind === "Sum")
84240
+ return pts.reduce((n, p) => n + (p.Sum ?? 0), 0);
84241
+ if (kind === "Maximum")
84242
+ return Math.max(...pts.map((p) => p.Maximum ?? 0));
84243
+ return pts.reduce((n, p) => n + (p.Average ?? 0), 0) / pts.length;
84244
+ }
84245
+ async function cwMetric(cw, namespace, name, dims, kind, start, end) {
84246
+ const pts = await cw.getMetricStatistics({ Namespace: namespace, MetricName: name, Dimensions: dims, StartTime: start, EndTime: end, Period: 86400, Statistics: [kind] }).catch(() => []);
84247
+ if (!pts.length)
84248
+ return 0;
84249
+ if (kind === "Sum")
84250
+ return pts.reduce((n, p) => n + (p.Sum ?? 0), 0);
84251
+ if (kind === "Maximum")
84252
+ return Math.max(...pts.map((p) => p.Maximum ?? 0));
84253
+ return pts.reduce((n, p) => n + (p.Average ?? 0), 0) / pts.length;
84254
+ }
84255
+ async function durationPct(cw, fn, p, start, end) {
84256
+ const pts = await cw.getMetricStatistics({
84257
+ Namespace: "AWS/Lambda",
84258
+ MetricName: "Duration",
84259
+ Dimensions: [{ Name: "FunctionName", Value: fn }],
84260
+ StartTime: start,
84261
+ EndTime: end,
84262
+ Period: 86400,
84263
+ ExtendedStatistics: [p]
84264
+ }).catch(() => []);
84265
+ return Math.round(pts.reduce((n, x) => n + (x.Percentiles?.[p] ?? 0), 0));
84266
+ }
84267
+ async function resolveDashboardData(config6, environment) {
84268
+ const info2 = await serverlessInfo(config6, environment);
84269
+ const region = info2.region;
84270
+ const cw = new CloudWatchClient(region);
84271
+ const lambda2 = new LambdaClient(region);
84272
+ const logs = new CloudWatchLogsClient(region);
84273
+ const end = new Date;
84274
+ const start = new Date(end.getTime() - 86400000);
84275
+ const app = config6.environments?.[environment]?.app;
84276
+ const out = {};
84277
+ out.app = {
84278
+ name: info2.slug,
84279
+ env: environment,
84280
+ region,
84281
+ runtime: app?.kind === "php" ? `php-${app?.phpVersion ?? "8.3"} (fpm)` : app?.kind ?? "node",
84282
+ url: info2.endpoint ?? "",
84283
+ build: info2.lastRelease?.sha?.slice(0, 7) ?? "—",
84284
+ deployedAt: info2.lastRelease?.timestamp ?? "—"
84285
+ };
84286
+ out.maintenance = { enabled: false };
84287
+ out.scheduler = { enabled: info2.scheduler !== "off", expression: info2.scheduler === "sub-minute" ? "rate(1 minute) · sub-minute" : "rate(1 minute)", lastRun: "—" };
84288
+ let totalInvocations = 0, totalErrors = 0, maxDur = 0;
84289
+ const fns = [];
84290
+ const fnsDetail = [];
84291
+ const serverlessLogs = [];
84292
+ for (const f of info2.functions) {
84293
+ const [invocations, errors, throttles, durAvg, cfg, p50, p95, p99, spark] = await Promise.all([
84294
+ lambdaMetric(cw, f.name, "Invocations", "Sum", start, end),
84295
+ lambdaMetric(cw, f.name, "Errors", "Sum", start, end),
84296
+ lambdaMetric(cw, f.name, "Throttles", "Sum", start, end),
84297
+ lambdaMetric(cw, f.name, "Duration", "Average", start, end),
84298
+ lambda2.getFunction(f.name).catch(() => null),
84299
+ durationPct(cw, f.name, "p50", start, end),
84300
+ durationPct(cw, f.name, "p95", start, end),
84301
+ durationPct(cw, f.name, "p99", start, end),
84302
+ cw.getMetricSeries({ Namespace: "AWS/Lambda", MetricName: "Invocations", Dimensions: [{ Name: "FunctionName", Value: f.name }], StartTime: new Date(end.getTime() - 12 * 3600000), EndTime: end, Period: 3600, Stat: "Sum" }).catch(() => [])
84303
+ ]);
84304
+ totalInvocations += invocations;
84305
+ totalErrors += errors;
84306
+ maxDur = Math.max(maxDur, durAvg);
84307
+ const memory = cfg?.Configuration?.MemorySize ?? 0;
84308
+ const env2 = Object.keys(cfg?.Configuration?.Environment?.Variables ?? {});
84309
+ let recent = [];
84310
+ let maxMem = 0;
84311
+ try {
84312
+ const { events = [] } = await logs.filterLogEvents({ logGroupName: `/aws/lambda/${f.name}`, startTime: start.getTime(), limit: 120 });
84313
+ for (const e of events) {
84314
+ const msg = (e.message ?? "").trim();
84315
+ const mm = /Max Memory Used:\s*(\d+)\s*MB/.exec(msg);
84316
+ if (mm)
84317
+ maxMem = Math.max(maxMem, Number(mm[1]));
84318
+ if (msg && !/^(?:START|END|REPORT|INIT_START)/.test(msg)) {
84319
+ serverlessLogs.push({
84320
+ source: f.mode || f.name,
84321
+ timestamp: new Date(e.timestamp ?? 0).toISOString(),
84322
+ when: relativeLogTime(e.timestamp ?? 0),
84323
+ message: msg.slice(0, 400),
84324
+ level: /error|exception|fatal|panic|traceback/i.test(msg) ? "error" : /warn|warning|timeout|retr(?:y|ies)|throttl/i.test(msg) ? "warn" : "info"
84325
+ });
84326
+ }
84327
+ }
84328
+ recent = events.filter((e) => !/^(?:START|END|REPORT|INIT_START)/.test((e.message ?? "").trim())).slice(-6).map((e) => ({ ts: new Date(e.timestamp ?? 0).toISOString().slice(11, 19), msg: (e.message ?? "").trim().slice(0, 120), err: /error|exception|fatal/i.test(e.message ?? "") }));
84329
+ } catch {}
84330
+ const maxSpark = Math.max(1, ...spark);
84331
+ fns.push({ key: f.mode, name: f.name, version: f.version, memory, timeout: cfg?.Configuration?.Timeout ?? 0, runtime: cfg?.Configuration?.Runtime ?? "provided.al2023", invocations: Math.round(invocations), errors: Math.round(errors), p95: p95 || Math.round(durAvg), status: "active", provisioned: f.provisioned ? `${f.provisioned.allocated}/${f.provisioned.requested}` : undefined });
84332
+ fnsDetail.push({
84333
+ key: f.mode,
84334
+ name: f.name,
84335
+ runtime: cfg?.Configuration?.Runtime ?? "provided.al2023",
84336
+ arch: cfg?.Configuration?.Architectures?.[0] ?? "x86_64",
84337
+ memory,
84338
+ timeout: cfg?.Configuration?.Timeout ?? 0,
84339
+ ephemeral: cfg?.Configuration?.EphemeralStorage?.Size ?? 512,
84340
+ version: f.version,
84341
+ concurrency: app?.concurrency ?? "—",
84342
+ invocations: Math.round(invocations),
84343
+ errors: Math.round(errors),
84344
+ throttles: Math.round(throttles),
84345
+ p50: p50 || Math.round(durAvg),
84346
+ p95: p95 || Math.round(durAvg),
84347
+ p99: p99 || Math.round(durAvg),
84348
+ maxMem,
84349
+ spark: spark.length ? spark.map((v) => Math.round(v / maxSpark * 100)) : [0],
84350
+ env: env2.length ? env2 : ["(none)"],
84351
+ recent: recent.length ? recent : [{ ts: "—", msg: "no recent events", err: false }]
84352
+ });
84353
+ }
84354
+ out.functions = fns;
84355
+ out.functionsDetail = fnsDetail;
84356
+ out.serverlessLogs = serverlessLogs.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp))).slice(0, 600);
84357
+ out.serverlessLogsEmptyReason = serverlessLogs.length ? undefined : "No recent function logs were found in CloudWatch for this environment.";
84358
+ const errorRatePct = totalInvocations ? Number((totalErrors / totalInvocations * 100).toFixed(2)) : 0;
84359
+ const concurrency = Math.round(await lambdaMetric(cw, info2.functions[0]?.name ?? "", "ConcurrentExecutions", "Maximum", start, end));
84360
+ out.metrics = { invocations: Math.round(totalInvocations), errorRatePct, p95Ms: Math.round(maxDur), coldStartPct: 0, concurrency, estCostUsd: 0 };
84361
+ try {
84362
+ const sqs2 = new SQSClient(region);
84363
+ const overview = [];
84364
+ const detail = [];
84365
+ let dlqDepth = 0;
84366
+ try {
84367
+ const { QueueUrl } = await sqs2.getQueueUrl(`${info2.slug}-${environment}-dlq`);
84368
+ const { Attributes } = await sqs2.getQueueAttributes(QueueUrl);
84369
+ dlqDepth = Number(Attributes.ApproximateNumberOfMessages ?? 0);
84370
+ } catch {}
84371
+ const resolvedQueues = app ? resolveQueues(app, info2.slug, environment) : [];
84372
+ for (const q of resolvedQueues) {
84373
+ const short = q.name.replace(`${info2.slug}-${environment}-`, "");
84374
+ let visible = 0, inFlight = 0;
84375
+ try {
84376
+ const { QueueUrl } = await sqs2.getQueueUrl(q.name);
84377
+ const { Attributes } = await sqs2.getQueueAttributes(QueueUrl);
84378
+ visible = Number(Attributes.ApproximateNumberOfMessages ?? 0);
84379
+ inFlight = Number(Attributes.ApproximateNumberOfMessagesNotVisible ?? 0);
84380
+ } catch {}
84381
+ const processed = Math.round(await cwMetric(cw, "AWS/SQS", "NumberOfMessagesDeleted", [{ Name: "QueueName", Value: q.name }], "Sum", start, end));
84382
+ overview.push({ name: short, visible, inFlight, processed, dlq: dlqDepth });
84383
+ detail.push({ name: q.name, short, visible, inFlight, delayed: 0, processed, oldestSec: 0, concurrency: q.concurrency ?? app?.queueConcurrency ?? "—", tries: app?.queueTries ?? 3, visTimeout: (app?.queueTimeout ?? 120) * 6, dlq: dlqDepth });
84384
+ }
84385
+ if (overview.length)
84386
+ out.queues = overview;
84387
+ if (detail.length)
84388
+ out.queuesDetail = detail;
84389
+ out.dlqItems = [];
84390
+ } catch {}
84391
+ try {
84392
+ const cf = new CloudFormationClient(region);
84393
+ const { Stacks } = await cf.describeStacks({ stackName: resolveServerlessAppStackName(config6, environment) });
84394
+ const outputs = {};
84395
+ for (const o of Stacks?.[0]?.Outputs ?? [])
84396
+ if (o.OutputKey)
84397
+ outputs[o.OutputKey] = o.OutputValue ?? "";
84398
+ if (app?.database?.connection === "aurora-serverless") {
84399
+ const clusterId = `${info2.slug}-${environment}-db`;
84400
+ const dim4 = [{ Name: "DBClusterIdentifier", Value: clusterId }];
84401
+ const [acu, conns] = await Promise.all([
84402
+ cwMetric(cw, "AWS/RDS", "ServerlessDatabaseCapacity", dim4, "Average", start, end),
84403
+ cwMetric(cw, "AWS/RDS", "DatabaseConnections", dim4, "Maximum", start, end)
84404
+ ]);
84405
+ out.aurora = { id: clusterId, engine: "aurora-mysql", minAcu: app?.database?.minCapacity ?? 0.5, maxAcu: app?.database?.maxCapacity ?? 4, currentAcu: acu ? Number(acu.toFixed(1)) : app?.database?.minCapacity ?? 0.5, database: "app", connections: Math.round(conns), status: outputs.DbEndpoint ? "available" : "creating" };
84406
+ if (app?.rdsProxy)
84407
+ out.proxy = { name: `${info2.slug}-${environment}-proxy`, endpoint: outputs.DbProxyEndpoint ?? "—", pooledConns: Math.round(conns), status: "available" };
84408
+ }
84409
+ if (app?.cache?.driver === "elasticache") {
84410
+ const rgId = `${info2.slug}-${environment}-cache`;
84411
+ const dim4 = [{ Name: "CacheClusterId", Value: `${rgId}-001` }];
84412
+ const [hits, misses] = await Promise.all([
84413
+ cwMetric(cw, "AWS/ElastiCache", "CacheHits", dim4, "Sum", start, end),
84414
+ cwMetric(cw, "AWS/ElastiCache", "CacheMisses", dim4, "Sum", start, end)
84415
+ ]);
84416
+ const hitRate = hits + misses > 0 ? Number((hits / (hits + misses) * 100).toFixed(1)) : 0;
84417
+ out.redis = { id: rgId, node: "cache.t4g.micro", engine: "redis", hitRate, status: outputs.CacheEndpoint ? "available" : "creating" };
84418
+ }
84419
+ if (app?.efs) {
84420
+ let sizeMb = 0;
84421
+ try {
84422
+ if (outputs.EfsFileSystemId) {
84423
+ const efs2 = new EFSClient(region);
84424
+ const { FileSystems } = await efs2.describeFileSystems({ FileSystemId: outputs.EfsFileSystemId });
84425
+ sizeMb = Number(((FileSystems?.[0]?.SizeInBytes?.Value ?? 0) / 1048576).toFixed(1));
84426
+ }
84427
+ } catch {}
84428
+ out.efs = { id: outputs.EfsFileSystemId ?? "efs", mount: "/mnt/local", sizeMb, status: "available" };
84429
+ }
84430
+ out.assetsInfo = outputs.AssetsCdnDomain ? { bucket: outputs.AssetsBucketName ?? `${info2.slug}-${environment}-assets`, cdn: outputs.AssetsCdnDomain, customDomain: app?.assetDomain ?? "—", assetUrl: info2.assetUrl ?? "—", files: 0, sizeMb: 0, cacheHitPct: 0, build: out.app.build } : undefined;
84431
+ } catch {}
84432
+ if (out.assetsInfo) {
84433
+ try {
84434
+ const s32 = new S3Client2(region);
84435
+ const objs = await s32.list({ bucket: out.assetsInfo.bucket, maxKeys: 1000 });
84436
+ out.assetsInfo.files = objs.length;
84437
+ out.assetsInfo.sizeMb = Number((objs.reduce((n, o) => n + (o.Size ?? 0), 0) / 1048576).toFixed(1));
84438
+ out.assetsRecent = objs.slice(0, 8).map((o) => ({ path: `/${o.Key}`, size: `${Math.max(1, Math.round((o.Size ?? 0) / 1024))} KB`, type: o.Key.split(".").pop() ?? "" }));
84439
+ } catch {}
84440
+ }
84441
+ try {
84442
+ if (app?.secrets) {
84443
+ const list = Array.isArray(app.secrets) ? app.secrets.map((s) => ({ key: s.split("/").pop().toUpperCase().replace(/[^A-Z0-9_]/g, "_"), source: s })) : Object.entries(app.secrets).map(([k, v]) => ({ key: k, source: String(v) }));
84444
+ out.secretsList = list.map((s) => ({ ...s, updated: "—" }));
84445
+ }
84446
+ } catch {}
84447
+ try {
84448
+ const s32 = new S3Client2(region);
84449
+ const bucket = `${info2.slug}-${environment}-deployments`;
84450
+ const hist = await s32.getObjectJson(bucket, `deployments/${info2.slug}/${environment}/history.json`).catch(() => null);
84451
+ if (hist?.records?.length) {
84452
+ out.deploymentsDetail = hist.records.slice(-12).reverse().map((r) => ({ sha: (r.sha ?? "").slice(0, 7), status: r.status ?? "success", when: r.timestamp ?? "—", took: r.took ?? "—", by: r.by ?? "—", version: r.version ?? "—", hooks: r.hooks ?? [] }));
84453
+ }
84454
+ } catch {}
84455
+ out.metricsTotals = { invocations: out.metrics.invocations, errors: Math.round(totalErrors), errorRatePct, p95Ms: out.metrics.p95Ms, throttles: fnsDetail.reduce((n, f) => n + f.throttles, 0), coldStartPct: 0, estCostUsd: 0, concurrency };
84456
+ out.metricsPerFn = fnsDetail.map((f) => ({ key: f.key, invocations: f.invocations, errors: f.errors, throttles: f.throttles, avgMs: f.p50, maxMs: f.p99, costUsd: 0 }));
84457
+ try {
84458
+ out.invSpark = await cw.getMetricSeries({ Namespace: "AWS/Lambda", MetricName: "Invocations", Dimensions: [{ Name: "FunctionName", Value: info2.functions.find((f) => f.mode === "http")?.name ?? "" }], StartTime: new Date(end.getTime() - 24 * 3600000), EndTime: end, Period: 3600, Stat: "Sum" });
84459
+ } catch {}
84460
+ try {
84461
+ const ce = new CostExplorerClient;
84462
+ const now = end;
84463
+ const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
84464
+ const prevStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
84465
+ const [thisMonth, lastMonth] = await Promise.all([
84466
+ ce.getCostByService({ start: isoDate(monthStart), end: isoDate(now), granularity: "MONTHLY" }).catch(() => []),
84467
+ ce.getCostByService({ start: isoDate(prevStart), end: isoDate(monthStart), granularity: "MONTHLY" }).catch(() => [])
84468
+ ]);
84469
+ if (thisMonth.length) {
84470
+ const mtd = thisMonth.reduce((n, s) => n + s.amount, 0);
84471
+ const lastTotal = lastMonth.reduce((n, s) => n + s.amount, 0);
84472
+ const dayOfMonth = now.getUTCDate();
84473
+ const daysInMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate();
84474
+ out.costSummary = { monthToDateUsd: Number(mtd.toFixed(2)), projectedUsd: Number((mtd / dayOfMonth * daysInMonth).toFixed(2)), lastMonthUsd: Number(lastTotal.toFixed(2)), dailyAvgUsd: Number((mtd / dayOfMonth).toFixed(2)) };
84475
+ out.costServices = thisMonth.filter((s) => s.amount > 0).sort((a, b) => b.amount - a.amount).slice(0, 12).map((s) => ({ name: s.service, usd: Number(s.amount.toFixed(2)), note: "" }));
84476
+ out.metrics.estCostUsd = Number((mtd / dayOfMonth).toFixed(2));
84477
+ out.metricsTotals.estCostUsd = out.metrics.estCostUsd;
84478
+ out.costTrend = await ce.getDailyTotals({ start: isoDate(monthStart), end: isoDate(now) }).catch(() => []);
84479
+ const lambdaCost = thisMonth.find((s) => /lambda/i.test(s.service))?.amount ?? 0;
84480
+ const gbSec = (f) => f.invocations * (f.p50 / 1000) * (f.memory / 1024);
84481
+ const totalGbSec = fnsDetail.reduce((n, f) => n + gbSec(f), 0) || 1;
84482
+ out.costPerFn = fnsDetail.map((f) => ({ key: f.key, requests: f.invocations, gbSec: Math.round(gbSec(f)), usd: Number((lambdaCost * (gbSec(f) / totalGbSec)).toFixed(2)) }));
84483
+ }
84484
+ } catch {}
84485
+ if (app?.firewall) {
84486
+ try {
84487
+ const blocked = await cw.getMetricStatistics({ Namespace: "AWS/WAFV2", MetricName: "BlockedRequests", StartTime: start, EndTime: end, Period: 86400, Statistics: ["Sum"] }).catch(() => []);
84488
+ const allowed = await cw.getMetricStatistics({ Namespace: "AWS/WAFV2", MetricName: "AllowedRequests", StartTime: start, EndTime: end, Period: 86400, Statistics: ["Sum"] }).catch(() => []);
84489
+ out.waf = { enabled: true, scope: "REGIONAL", acl: `${info2.slug}-${environment}-waf`, allowed24h: Math.round(allowed.reduce((n, p) => n + (p.Sum ?? 0), 0)), blocked24h: Math.round(blocked.reduce((n, p) => n + (p.Sum ?? 0), 0)) };
84490
+ try {
84491
+ const waf = new WAFv2Client(region);
84492
+ const acls = await waf.listWebACLs("REGIONAL");
84493
+ const acl = acls.find((a) => a.Name?.includes(`${info2.slug}-${environment}`)) ?? acls[0];
84494
+ if (acl?.Name && acl.Id) {
84495
+ out.waf.acl = acl.Name;
84496
+ const rules = await waf.getWebACLRules(acl.Name, acl.Id, "REGIONAL");
84497
+ if (rules.length)
84498
+ out.wafRules = rules.map((r) => ({ name: r.Name ?? "—", detail: `priority ${r.Priority ?? 0}`, action: r.Action ?? "count", blocked24h: 0 }));
84499
+ }
84500
+ } catch {}
84501
+ } catch {}
84502
+ }
84503
+ if (info2.scheduler !== "off" && app?.kind === "php") {
84504
+ try {
84505
+ const out2 = await runRemoteCommand(config6, environment, "schedule:list");
84506
+ const tasks = [];
84507
+ for (const line of (out2 || "").split(`
84508
+ `)) {
84509
+ const m = /^\s*([\d*/,\-\s]+?)\s{2,}(.+?)(?:\s{2,}Next Due.*)?$/.exec(line);
84510
+ if (m && /[*\d]/.test(m[1]) && m[1].trim().split(/\s+/).length === 5)
84511
+ tasks.push({ command: m[2].trim().replace(/^php artisan /, ""), cron: m[1].trim(), desc: "", lastRun: "—", lastStatus: "ok" });
84512
+ }
84513
+ if (tasks.length)
84514
+ out.schedulerTasks = tasks;
84515
+ } catch {}
84516
+ }
84517
+ return out;
84518
+ }
84519
+ // src/deploy/local-dashboard-server.ts
84520
+ import { existsSync as existsSync26, statSync as statSync6 } from "node:fs";
84521
+ import { readFile as readFile2, writeFile as writeFile6 } from "node:fs/promises";
84522
+ import { mkdtempSync as mkdtempSync6 } from "node:fs";
84523
+ import { tmpdir as tmpdir7 } from "node:os";
84524
+ import { dirname as dirname9, extname as extname3, join as join23, normalize } from "node:path";
84525
+ import { fileURLToPath as fileURLToPath6 } from "node:url";
84526
+
84527
+ // src/deploy/dashboard-data-server.ts
84528
+ import { existsSync as existsSync24, readFileSync as readFileSync17 } from "node:fs";
84529
+ import { join as join21 } from "node:path";
84530
+
83810
84531
  // src/drivers/factory.ts
83811
84532
  await init_dist2();
83812
84533
 
83813
84534
  // src/drivers/aws/driver.ts
83814
84535
  init_cloudformation();
83815
84536
  await init_dist2();
83816
- import { readFileSync as readFileSync14 } from "node:fs";
84537
+ import { readFileSync as readFileSync15 } from "node:fs";
83817
84538
  init_s3();
83818
84539
 
83819
84540
  // src/drivers/shared/package-manager.ts
@@ -84671,6 +85392,26 @@ function buildBackupProvisionScript(options) {
84671
85392
  `chmod 644 ${BACKUP_CRON_PATH}`
84672
85393
  ];
84673
85394
  }
85395
+ function engineSocket(engine) {
85396
+ return engine === "mariadb" ? "/var/lib/pantry/mariadb/mariadbd.sock" : "/var/lib/pantry/mysql/mysqld.sock";
85397
+ }
85398
+ function buildBackupRestoreScript(database, options = {}) {
85399
+ if (!database?.name)
85400
+ return [];
85401
+ const name = database.name;
85402
+ const isPg = database.engine === "postgres";
85403
+ const locate = options.from ? `TS_CLOUD_DUMP="${options.from}"` : `TS_CLOUD_DUMP="$(find ${BACKUP_OUTPUT_DIR} -type f -name '*${name}*.sql' -o -type f -name '*${name}*.sql.gz' 2>/dev/null | xargs -r ls -1t 2>/dev/null | head -1)"`;
85404
+ const client2 = isPg ? `psql -h 127.0.0.1 -p ${database.port ?? 5432} -U postgres -d "${name}"` : `mysql --socket=${engineSocket(database.engine)} -u root "${name}"`;
85405
+ return [
85406
+ "set -uo pipefail",
85407
+ 'eval "$(cd /opt/pantry && pantry env 2>/dev/null)" || true',
85408
+ locate,
85409
+ '[ -n "$TS_CLOUD_DUMP" ] && [ -f "$TS_CLOUD_DUMP" ] || { echo "no backup dump found to restore" >&2; exit 1; }',
85410
+ 'echo "[ts-cloud] restoring ' + name + ' from $TS_CLOUD_DUMP"',
85411
+ `case "$TS_CLOUD_DUMP" in *.gz) gunzip -c "$TS_CLOUD_DUMP" ;; *) cat "$TS_CLOUD_DUMP" ;; esac | ${client2}`,
85412
+ 'echo "restore complete"'
85413
+ ];
85414
+ }
84674
85415
 
84675
85416
  // src/drivers/shared/compute-provision.ts
84676
85417
  function buildComputeProvisionScripts(config6) {
@@ -85050,7 +85791,7 @@ class AwsDriver {
85050
85791
  await s32.putObject({
85051
85792
  bucket,
85052
85793
  key: options.remoteKey,
85053
- body: readFileSync14(options.localPath),
85794
+ body: readFileSync15(options.localPath),
85054
85795
  contentType: "application/gzip"
85055
85796
  });
85056
85797
  return { artifactRef: `s3://${bucket}/${options.remoteKey}` };
@@ -85157,9 +85898,9 @@ class AwsDriver {
85157
85898
 
85158
85899
  // src/drivers/hetzner/driver.ts
85159
85900
  await init_dist2();
85160
- import { existsSync as existsSync18, readFileSync as readFileSync15 } from "node:fs";
85161
- import { homedir as homedir7 } from "node:os";
85162
- import { join as join18 } from "node:path";
85901
+ import { existsSync as existsSync20, readFileSync as readFileSync16 } from "node:fs";
85902
+ import { homedir as homedir8 } from "node:os";
85903
+ import { join as join19 } from "node:path";
85163
85904
  import { execSync as execSync3 } from "node:child_process";
85164
85905
 
85165
85906
  // src/drivers/hetzner/client.ts
@@ -85379,6 +86120,27 @@ runcmd:
85379
86120
 
85380
86121
  // src/drivers/shared/rpx-gateway.ts
85381
86122
  var DEFAULT_RPX_CERTS_DIR = "/etc/rpx/certs";
86123
+ var DEFAULT_ACME_WEBROOT = "/var/www/acme-challenge";
86124
+ function normalizeSiteRedirect(input) {
86125
+ if (typeof input === "string")
86126
+ return { to: input };
86127
+ const out = { to: input.to };
86128
+ if (input.status != null)
86129
+ out.status = input.status;
86130
+ if (input.preservePath != null)
86131
+ out.preservePath = input.preservePath;
86132
+ return out;
86133
+ }
86134
+ function resolveRouteAuth(site) {
86135
+ const auth = site.auth;
86136
+ if (!auth || auth.enabled === false || !auth.password)
86137
+ return;
86138
+ return {
86139
+ username: auth.username || "admin",
86140
+ password: auth.password,
86141
+ ...auth.realm ? { realm: auth.realm } : {}
86142
+ };
86143
+ }
85382
86144
  function normalizeRoutePath(path) {
85383
86145
  if (!path || path === "/")
85384
86146
  return;
@@ -85405,17 +86167,24 @@ function buildRpxConfig(sites, options) {
85405
86167
  continue;
85406
86168
  const path = normalizeRoutePath(site.path);
85407
86169
  const id = deriveRouteId(site.domain, path);
86170
+ const auth = resolveRouteAuth(site);
86171
+ if (kind === "redirect") {
86172
+ proxies.push({ to: site.domain, path, redirect: normalizeSiteRedirect(site.redirect), id, ...auth ? { auth } : {} });
86173
+ domains.add(site.domain);
86174
+ continue;
86175
+ }
85408
86176
  if (kind === "server-app") {
85409
86177
  if (typeof site.port !== "number")
85410
86178
  continue;
85411
- proxies.push({ to: site.domain, path, from: `localhost:${site.port}`, id });
86179
+ proxies.push({ to: site.domain, path, from: `localhost:${site.port}`, id, ...auth ? { auth } : {} });
85412
86180
  } else {
85413
86181
  proxies.push({
85414
86182
  to: site.domain,
85415
86183
  path,
85416
- static: `${wwwRoot}/${name}`,
86184
+ static: `${wwwRoot}/${name}/current`,
85417
86185
  cleanUrls: site.pathRewriteStyle !== "flat",
85418
86186
  spa: site.spa ?? false,
86187
+ ...auth ? { auth } : {},
85419
86188
  id
85420
86189
  });
85421
86190
  }
@@ -85441,6 +86210,9 @@ function buildRpxConfig(sites, options) {
85441
86210
  certsDir
85442
86211
  };
85443
86212
  }
86213
+ if (options.proxy.onDemandTls) {
86214
+ config6.acmeChallengeWebroot = options.proxy.acmeWebroot ?? DEFAULT_ACME_WEBROOT;
86215
+ }
85444
86216
  const cdn = options.proxy.cdn;
85445
86217
  if (cdn?.secret && cdn.frontedHosts.length > 0) {
85446
86218
  config6.originGuard = {
@@ -85451,20 +86223,61 @@ function buildRpxConfig(sites, options) {
85451
86223
  }
85452
86224
  return config6;
85453
86225
  }
85454
- function renderRpxLauncher(config6) {
85455
- const json = JSON.stringify(config6, null, 2);
85456
- return `// Generated by ts-cloud — rpx reverse-proxy gateway.
85457
- // Routes are derived from the \`sites\` model on every \`buddy deploy\`.
85458
- import { startProxies } from '@stacksjs/rpx'
85459
-
85460
- const config = ${json} as const
85461
-
85462
- await startProxies(config as any)
85463
- `;
85464
- }
85465
86226
  var RPX_DIR = "/etc/rpx";
86227
+ var RPX_INSTALL_DIR = "/opt/rpx-gateway";
85466
86228
  var RPX_LAUNCHER_PATH = "/etc/rpx/gateway.ts";
85467
86229
  var RPX_SERVICE_NAME = "rpx-gateway.service";
86230
+ var RPX_SITES_DIR = "/etc/rpx/sites.d";
86231
+ function renderRpxAssembler(sitesDir = RPX_SITES_DIR, defaultCertsDir = DEFAULT_RPX_CERTS_DIR) {
86232
+ return `// Generated by ts-cloud — rpx gateway assembler.
86233
+ // Merges every app's fragment in ${sitesDir} so independent deploys compose
86234
+ // without clobbering each other. Each deploy writes only its own <slug>.json.
86235
+ import { startProxies } from '@stacksjs/rpx'
86236
+ import { readdirSync, readFileSync } from 'node:fs'
86237
+
86238
+ const dir = ${JSON.stringify(sitesDir)}
86239
+ const proxies = []
86240
+ const seen = new Set()
86241
+ const suffixes = new Set()
86242
+ const guardHosts = new Set()
86243
+ let email
86244
+ let certsDir = ${JSON.stringify(defaultCertsDir)}
86245
+ let acmeChallengeWebroot
86246
+ let guard
86247
+ let files = []
86248
+ try { files = readdirSync(dir).filter(n => n.endsWith('.json')).sort() } catch {}
86249
+ for (const f of files) {
86250
+ let frag
86251
+ try { frag = JSON.parse(readFileSync(dir + '/' + f, 'utf8')) } catch { continue }
86252
+ for (const p of frag.proxies ?? []) {
86253
+ const key = p.id || (p.to + (p.path ?? ''))
86254
+ if (seen.has(key)) continue
86255
+ seen.add(key)
86256
+ proxies.push(p)
86257
+ }
86258
+ for (const s of frag.onDemandTls?.allowedSuffixes ?? []) suffixes.add(s)
86259
+ email ??= frag.onDemandTls?.email
86260
+ if (frag.productionCerts?.certsDir) certsDir = frag.productionCerts.certsDir
86261
+ acmeChallengeWebroot ??= frag.acmeChallengeWebroot
86262
+ if (frag.originGuard) {
86263
+ guard ??= { header: frag.originGuard.header, value: frag.originGuard.value }
86264
+ for (const h of frag.originGuard.hosts ?? []) guardHosts.add(h)
86265
+ }
86266
+ }
86267
+ const config = {
86268
+ proxies,
86269
+ productionCerts: { certsDir },
86270
+ https: true,
86271
+ hostsManagement: false,
86272
+ cleanup: { hosts: false, certs: false },
86273
+ ...(suffixes.size > 0 ? { onDemandTls: { enabled: true, allowedSuffixes: [...suffixes], email, certsDir } } : {}),
86274
+ ...(acmeChallengeWebroot ? { acmeChallengeWebroot } : {}),
86275
+ ...(guard ? { originGuard: { header: guard.header, value: guard.value, hosts: [...guardHosts] } } : {}),
86276
+ }
86277
+
86278
+ await startProxies(config)
86279
+ `;
86280
+ }
85468
86281
  function writeFileHeredoc(path, content, delimiter) {
85469
86282
  return [
85470
86283
  `cat > ${path} <<'${delimiter}'`,
@@ -85472,21 +86285,110 @@ function writeFileHeredoc(path, content, delimiter) {
85472
86285
  delimiter
85473
86286
  ];
85474
86287
  }
86288
+ function certDomainsForConfig(config6) {
86289
+ const seen = new Set;
86290
+ for (const r of config6.proxies) {
86291
+ const host = r.to;
86292
+ if (!host || host.startsWith("*") || host.includes(":") || !host.includes("."))
86293
+ continue;
86294
+ seen.add(host);
86295
+ }
86296
+ return [...seen];
86297
+ }
86298
+ function buildCertManagementCommands(options) {
86299
+ const { config: config6, proxy } = options;
86300
+ const webroot = config6.acmeChallengeWebroot;
86301
+ const domains = certDomainsForConfig(config6);
86302
+ if (!proxy.onDemandTls || !webroot || domains.length === 0)
86303
+ return [];
86304
+ const bunBin = options.bunBin ?? "/usr/local/bin/bun";
86305
+ const version2 = proxy.version ?? "latest";
86306
+ const certsDir = config6.productionCerts.certsDir;
86307
+ const email3 = proxy.onDemandTlsEmail ?? `webmaster@${domains[0]}`;
86308
+ const tlsxCli = `${bunBin} ${RPX_INSTALL_DIR}/node_modules/@stacksjs/tlsx/dist/bin/cli.js`;
86309
+ const csv = domains.join(",");
86310
+ const spaced = domains.join(" ");
86311
+ const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
86312
+ const renewScriptPath = `${RPX_DIR}/renew-certs-${slug}.sh`;
86313
+ const renewServiceName = `rpx-cert-renew-${slug}.service`;
86314
+ const renewTimerName = `rpx-cert-renew-${slug}.timer`;
86315
+ const renewScript = [
86316
+ "#!/bin/sh",
86317
+ "# Generated by ts-cloud — issue/renew rpx gateway TLS certs via tlsx http-01.",
86318
+ "# The running gateway serves the challenge from $WEBROOT on :80, so this needs",
86319
+ "# no downtime and no DNS credentials. Reloads the gateway only if a cert changed.",
86320
+ "set -u",
86321
+ `CERTS='${certsDir}'`,
86322
+ `WEBROOT='${webroot}'`,
86323
+ `EMAIL='${email3}'`,
86324
+ `TLSX="${tlsxCli}"`,
86325
+ `DOMAINS='${csv}'`,
86326
+ 'before=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
86327
+ `for d in ${spaced}; do`,
86328
+ ' [ -s "$CERTS/$d.crt" ] || $TLSX acme:issue -d "$d" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --prod --email "$EMAIL" || echo "issue $d failed (non-fatal)"',
86329
+ "done",
86330
+ '$TLSX acme:renew --domains "$DOMAINS" --method http-01 --webroot "$WEBROOT" --dir "$CERTS" --days 30 --prod --email "$EMAIL" || echo "renew: some domains failed (non-fatal)"',
86331
+ 'rm -f "$CERTS"/*.chain.crt',
86332
+ 'after=$(cat "$CERTS"/*.crt 2>/dev/null | sha256sum)',
86333
+ `[ "$before" = "$after" ] || systemctl restart ${RPX_SERVICE_NAME}`
86334
+ ].join(`
86335
+ `);
86336
+ const renewService = [
86337
+ "[Unit]",
86338
+ `Description=Issue/renew rpx gateway TLS certs for ${slug} (tlsx http-01)`,
86339
+ `After=network-online.target ${RPX_SERVICE_NAME}`,
86340
+ "Wants=network-online.target",
86341
+ "",
86342
+ "[Service]",
86343
+ "Type=oneshot",
86344
+ `ExecStart=${renewScriptPath}`
86345
+ ].join(`
86346
+ `);
86347
+ const renewTimer = [
86348
+ "[Unit]",
86349
+ `Description=Daily rpx gateway TLS cert issuance/renewal for ${slug}`,
86350
+ "",
86351
+ "[Timer]",
86352
+ "OnCalendar=*-*-* 03:30:00",
86353
+ "RandomizedDelaySec=1h",
86354
+ "Persistent=true",
86355
+ "",
86356
+ "[Install]",
86357
+ "WantedBy=timers.target"
86358
+ ].join(`
86359
+ `);
86360
+ return [
86361
+ `mkdir -p ${webroot}`,
86362
+ `(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/tlsx@${version2}) || true`,
86363
+ ...writeFileHeredoc(renewScriptPath, renewScript, "TS_CLOUD_RENEW_EOF"),
86364
+ `chmod +x ${renewScriptPath}`,
86365
+ ...writeFileHeredoc(`/etc/systemd/system/${renewServiceName}`, renewService, "TS_CLOUD_RENEW_SVC_EOF"),
86366
+ ...writeFileHeredoc(`/etc/systemd/system/${renewTimerName}`, renewTimer, "TS_CLOUD_RENEW_TIMER_EOF"),
86367
+ "systemctl daemon-reload",
86368
+ `systemctl enable --now ${renewTimerName} || true`,
86369
+ `${renewScriptPath} || true`
86370
+ ];
86371
+ }
85475
86372
  function buildRpxProvisionScript(options) {
85476
86373
  const { config: config6, proxy } = options;
85477
86374
  const bunBin = options.bunBin ?? "/usr/local/bin/bun";
85478
86375
  const version2 = proxy.version ?? "latest";
85479
86376
  const certsDir = config6.productionCerts.certsDir;
85480
- const launcher = renderRpxLauncher(config6);
86377
+ const slug = (options.slug || "app").replace(/[^a-z0-9._-]+/gi, "-");
86378
+ const fragment = JSON.stringify({ slug, ...config6 }, null, 2);
86379
+ const assembler = renderRpxAssembler(RPX_SITES_DIR, certsDir);
85481
86380
  const upstreamTimeout = proxy.upstreamTimeout ?? 60;
85482
86381
  const poolEnv = [`Environment=RPX_UPSTREAM_TIMEOUT=${upstreamTimeout}`];
85483
86382
  if (typeof proxy.maxUpstreamConns === "number")
85484
86383
  poolEnv.push(`Environment=RPX_MAX_UPSTREAM_CONNS=${proxy.maxUpstreamConns}`);
85485
86384
  return [
85486
86385
  "set -euo pipefail",
85487
- `mkdir -p ${RPX_DIR} ${certsDir}`,
85488
- `mkdir -p /tmp/ts-cloud-rpx-install && (cd /tmp/ts-cloud-rpx-install && ${bunBin} add -g @stacksjs/rpx@${version2})`,
85489
- ...writeFileHeredoc(RPX_LAUNCHER_PATH, launcher, "TS_CLOUD_RPX_EOF"),
86386
+ `mkdir -p ${RPX_DIR} ${RPX_SITES_DIR} ${certsDir} ${RPX_INSTALL_DIR}`,
86387
+ `rm -rf ${RPX_INSTALL_DIR}/node_modules ${RPX_INSTALL_DIR}/bun.lock ${RPX_INSTALL_DIR}/package.json`,
86388
+ `(cd ${RPX_INSTALL_DIR} && ${bunBin} add @stacksjs/rpx@${version2})`,
86389
+ `ln -sfn ${RPX_INSTALL_DIR}/node_modules ${RPX_DIR}/node_modules`,
86390
+ ...writeFileHeredoc(`${RPX_SITES_DIR}/${slug}.json`, fragment, "TS_CLOUD_RPX_FRAGMENT_EOF"),
86391
+ ...writeFileHeredoc(RPX_LAUNCHER_PATH, assembler, "TS_CLOUD_RPX_EOF"),
85490
86392
  ...writeFileHeredoc(`/etc/systemd/system/${RPX_SERVICE_NAME}`, [
85491
86393
  "[Unit]",
85492
86394
  "Description=rpx reverse-proxy gateway (managed by ts-cloud)",
@@ -85496,6 +86398,7 @@ function buildRpxProvisionScript(options) {
85496
86398
  "[Service]",
85497
86399
  "Type=simple",
85498
86400
  `ExecStart=${bunBin} ${RPX_LAUNCHER_PATH}`,
86401
+ `WorkingDirectory=${RPX_INSTALL_DIR}`,
85499
86402
  `Environment=BUN_INSTALL=/root/.bun`,
85500
86403
  ...poolEnv,
85501
86404
  "Restart=always",
@@ -85511,7 +86414,8 @@ function buildRpxProvisionScript(options) {
85511
86414
  "systemctl disable --now bun-gateway.service 2>/dev/null || true",
85512
86415
  "systemctl disable --now ts-cloud-nginx.service 2>/dev/null || true",
85513
86416
  `systemctl enable ${RPX_SERVICE_NAME}`,
85514
- `systemctl restart ${RPX_SERVICE_NAME}`
86417
+ `systemctl restart ${RPX_SERVICE_NAME}`,
86418
+ ...buildCertManagementCommands(options)
85515
86419
  ];
85516
86420
  }
85517
86421
 
@@ -85596,10 +86500,10 @@ function matchesTsCloudLabels(labels, slug, environment, role = "app") {
85596
86500
 
85597
86501
  // src/drivers/hetzner/state.ts
85598
86502
  import { mkdir as mkdir4, readFile, writeFile as writeFile4 } from "node:fs/promises";
85599
- import { join as join17 } from "node:path";
86503
+ import { join as join18 } from "node:path";
85600
86504
  var STATE_DIR = ".ts-cloud/state";
85601
86505
  function driverStatePath(stackName) {
85602
- return join17(process.cwd(), STATE_DIR, `${stackName}.json`);
86506
+ return join18(process.cwd(), STATE_DIR, `${stackName}.json`);
85603
86507
  }
85604
86508
  async function readDriverState(stackName) {
85605
86509
  try {
@@ -85611,7 +86515,7 @@ async function readDriverState(stackName) {
85611
86515
  }
85612
86516
  async function writeDriverState(stackName, state) {
85613
86517
  const path = driverStatePath(stackName);
85614
- await mkdir4(join17(process.cwd(), STATE_DIR), { recursive: true });
86518
+ await mkdir4(join18(process.cwd(), STATE_DIR), { recursive: true });
85615
86519
  await writeFile4(path, `${JSON.stringify(state, null, 2)}
85616
86520
  `, "utf8");
85617
86521
  }
@@ -85619,7 +86523,7 @@ async function writeDriverState(stackName, state) {
85619
86523
  // src/drivers/hetzner/driver.ts
85620
86524
  var SSH_MAX_BUFFER = 1024 * 1024 * 256;
85621
86525
  function expandHome(path) {
85622
- return path.startsWith("~/") ? join18(homedir7(), path.slice(2)) : path;
86526
+ return path.startsWith("~/") ? join19(homedir8(), path.slice(2)) : path;
85623
86527
  }
85624
86528
 
85625
86529
  class HetznerDriver {
@@ -85687,6 +86591,7 @@ class HetznerDriver {
85687
86591
  const rpxProvision = compute.proxy?.engine === "rpx" ? buildRpxProvisionScript({
85688
86592
  proxy: compute.proxy,
85689
86593
  config: buildRpxConfig(sites, { proxy: compute.proxy }),
86594
+ slug: config6.project.slug,
85690
86595
  bunBin: compute.runtime === "node" || compute.runtime === "deno" ? undefined : "/usr/local/bin/bun"
85691
86596
  }) : undefined;
85692
86597
  const provision = buildComputeProvisionScripts(config6);
@@ -86049,10 +86954,10 @@ class HetznerDriver {
86049
86954
  };
86050
86955
  }
86051
86956
  async ensureSshKey(slug, environment, labels) {
86052
- if (!existsSync18(this.sshPublicKeyPath)) {
86957
+ if (!existsSync20(this.sshPublicKeyPath)) {
86053
86958
  throw new Error(`SSH public key not found at ${this.sshPublicKeyPath}. ts-cloud deploys to Hetzner over SSH and needs a public key to authorize on the server. ` + `Generate one (\`ssh-keygen -t ed25519\`) or set hetzner.sshPrivateKeyPath / HCLOUD_SSH_PUBLIC_KEY.`);
86054
86959
  }
86055
- const publicKey = readFileSync15(this.sshPublicKeyPath, "utf8").trim();
86960
+ const publicKey = readFileSync16(this.sshPublicKeyPath, "utf8").trim();
86056
86961
  const normalized = normalizeSshPublicKey(publicKey);
86057
86962
  const existing = await this.client.listSshKeys();
86058
86963
  const match = existing.find((key) => normalizeSshPublicKey(key.public_key) === normalized);
@@ -86184,8 +87089,53 @@ ${out}`);
86184
87089
  }
86185
87090
  }
86186
87091
 
87092
+ // src/drivers/local-box/driver.ts
87093
+ class LocalBoxDriver {
87094
+ name = "hetzner";
87095
+ usesCloudFormation = false;
87096
+ async getComputeOutputs() {
87097
+ return { appPublicIp: "127.0.0.1", sshUser: "root" };
87098
+ }
87099
+ async uploadRelease(options) {
87100
+ return { artifactRef: options.localPath };
87101
+ }
87102
+ async findComputeTargets(_options) {
87103
+ return [{ id: "localhost", name: "localhost", publicIp: "127.0.0.1", status: "running" }];
87104
+ }
87105
+ async runRemoteDeploy(options) {
87106
+ const script = options.commands.join(`
87107
+ `);
87108
+ try {
87109
+ const proc = Bun.spawn(["bash", "-c", script], { stdout: "pipe", stderr: "pipe", env: process.env });
87110
+ const [output, error2, exitCode] = await Promise.all([
87111
+ new Response(proc.stdout).text(),
87112
+ new Response(proc.stderr).text(),
87113
+ proc.exited
87114
+ ]);
87115
+ return {
87116
+ success: exitCode === 0,
87117
+ instanceCount: 1,
87118
+ perInstance: [{ instanceId: "localhost", status: exitCode === 0 ? "Success" : "Failed", output, error: error2 }]
87119
+ };
87120
+ } catch (err) {
87121
+ return {
87122
+ success: false,
87123
+ instanceCount: 1,
87124
+ perInstance: [{ instanceId: "localhost", status: "Failed", error: err?.message ?? String(err) }],
87125
+ error: err?.message ?? String(err)
87126
+ };
87127
+ }
87128
+ }
87129
+ }
87130
+ function isBoxMode() {
87131
+ const v = process.env.TS_CLOUD_DASHBOARD_BOX;
87132
+ return v != null && v !== "" && v !== "0" && v.toLowerCase() !== "false";
87133
+ }
87134
+
86187
87135
  // src/drivers/factory.ts
86188
87136
  function createCloudDriver(options) {
87137
+ if (isBoxMode())
87138
+ return new LocalBoxDriver;
86189
87139
  const provider = options.provider ?? resolveCloudProvider(options.config);
86190
87140
  switch (provider) {
86191
87141
  case "aws":
@@ -86226,6 +87176,111 @@ function quoteEnvValue(value) {
86226
87176
  return `"${escaped}"`;
86227
87177
  }
86228
87178
 
87179
+ // src/drivers/shared/releases.ts
87180
+ var DEFAULT_SHARED_PATHS = ["storage", ".env"];
87181
+ var DEFAULT_KEEP_RELEASES = 4;
87182
+ var DEFAULT_KEEP_DEPLOY_LOGS = 20;
87183
+ function deployMetaDir(base) {
87184
+ return `${base.replace(/\/+$/, "")}/.ts-cloud`;
87185
+ }
87186
+ function deployHistoryPath(base) {
87187
+ return `${deployMetaDir(base)}/deploy-history.log`;
87188
+ }
87189
+ function deployLogPath(base, releaseId) {
87190
+ return `${deployMetaDir(base)}/deploys/${releaseId}.log`;
87191
+ }
87192
+ function releasePaths(base, releaseId) {
87193
+ const root = base.replace(/\/+$/, "");
87194
+ return {
87195
+ base: root,
87196
+ releases: `${root}/releases`,
87197
+ shared: `${root}/shared`,
87198
+ current: `${root}/current`,
87199
+ release: `${root}/releases/${releaseId}`
87200
+ };
87201
+ }
87202
+ function isFileSharedPath(p) {
87203
+ const name = p.split("/").pop() || p;
87204
+ return name.startsWith(".") || /\.[a-z0-9]+$/i.test(name);
87205
+ }
87206
+ function buildEnsureReleaseLayout(paths, sharedPaths = DEFAULT_SHARED_PATHS) {
87207
+ const lines = [
87208
+ `mkdir -p ${paths.releases} ${paths.shared}`
87209
+ ];
87210
+ for (const p of sharedPaths) {
87211
+ if (isFileSharedPath(p)) {
87212
+ lines.push(`mkdir -p "$(dirname ${paths.shared}/${p})"`, `touch ${paths.shared}/${p}`);
87213
+ } else if (p === "storage") {
87214
+ lines.push(`mkdir -p ${paths.shared}/storage/app/public`, `mkdir -p ${paths.shared}/storage/framework/cache/data`, `mkdir -p ${paths.shared}/storage/framework/sessions`, `mkdir -p ${paths.shared}/storage/framework/testing`, `mkdir -p ${paths.shared}/storage/framework/views`, `mkdir -p ${paths.shared}/storage/logs`);
87215
+ } else {
87216
+ lines.push(`mkdir -p ${paths.shared}/${p}`);
87217
+ }
87218
+ }
87219
+ return lines;
87220
+ }
87221
+ function buildLinkSharedPaths(paths, sharedPaths = DEFAULT_SHARED_PATHS) {
87222
+ const lines = [];
87223
+ for (const p of sharedPaths) {
87224
+ const target = `${paths.shared}/${p}`;
87225
+ const link = `${paths.release}/${p}`;
87226
+ lines.push(`rm -rf ${link}`, `mkdir -p "$(dirname ${link})"`, `ln -sfn ${target} ${link}`);
87227
+ }
87228
+ return lines;
87229
+ }
87230
+ function buildActivateRelease(paths) {
87231
+ return [
87232
+ `ln -sfn ${paths.release} ${paths.current}.tmp`,
87233
+ `mv -Tf ${paths.current}.tmp ${paths.current}`
87234
+ ];
87235
+ }
87236
+ function buildRollbackScript(paths, options = {}) {
87237
+ if (options.to) {
87238
+ const target = `${paths.releases}/${options.to}`;
87239
+ return [
87240
+ `[ -d ${target} ] || { echo "rollback target ${target} not found" >&2; exit 1; }`,
87241
+ `ln -sfn ${target} ${paths.current}.tmp`,
87242
+ `mv -Tf ${paths.current}.tmp ${paths.current}`
87243
+ ];
87244
+ }
87245
+ return [
87246
+ `TS_CLOUD_CURRENT=$(readlink -f ${paths.current} 2>/dev/null || true)`,
87247
+ `TS_CLOUD_PREV=$(ls -1dt ${paths.releases}/*/ 2>/dev/null | sed 's#/$##' | while read -r r; do ` + '[ "$(readlink -f "$r")" != "$TS_CLOUD_CURRENT" ] && { echo "$r"; break; }; done)',
87248
+ '[ -n "$TS_CLOUD_PREV" ] || { echo "no previous release to roll back to" >&2; exit 1; }',
87249
+ `ln -sfn "$TS_CLOUD_PREV" ${paths.current}.tmp`,
87250
+ `mv -Tf ${paths.current}.tmp ${paths.current}`,
87251
+ 'echo "rolled back to $TS_CLOUD_PREV"'
87252
+ ];
87253
+ }
87254
+ function buildPruneReleases(paths, keep = DEFAULT_KEEP_RELEASES) {
87255
+ const n = Math.max(1, keep);
87256
+ return [
87257
+ `TS_CLOUD_CURRENT=$(readlink -f ${paths.current} 2>/dev/null || true)`,
87258
+ `ls -1dt ${paths.releases}/*/ 2>/dev/null | sed 's#/$##' | tail -n +${n + 1} | while read -r TS_CLOUD_OLD; do`,
87259
+ ' [ "$(readlink -f "$TS_CLOUD_OLD")" = "$TS_CLOUD_CURRENT" ] || rm -rf "$TS_CLOUD_OLD"',
87260
+ "done"
87261
+ ];
87262
+ }
87263
+ function buildDeployHistoryHeader(base, options) {
87264
+ const meta = deployMetaDir(base);
87265
+ const log4 = deployLogPath(base, options.releaseId);
87266
+ const history = deployHistoryPath(base);
87267
+ const keepLogs = Math.max(1, options.keepLogs ?? DEFAULT_KEEP_DEPLOY_LOGS);
87268
+ const commit = options.commit || "";
87269
+ const branch = options.branch || "";
87270
+ return [
87271
+ `mkdir -p ${meta}/deploys`,
87272
+ `exec > >(tee -a ${log4}) 2>&1`,
87273
+ `echo "[ts-cloud] deploy ${options.releaseId} commit=${commit} branch=${branch} starting $(date -u +%Y-%m-%dT%H:%M:%SZ)"`,
87274
+ "ts_cloud_record_deploy() {",
87275
+ " TS_CLOUD_RC=$?",
87276
+ ' if [ "$TS_CLOUD_RC" -eq 0 ]; then TS_CLOUD_ST=success; else TS_CLOUD_ST=failed; fi',
87277
+ ` 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}`,
87278
+ "}",
87279
+ "trap ts_cloud_record_deploy EXIT",
87280
+ `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`
87281
+ ];
87282
+ }
87283
+
86229
87284
  // src/drivers/shared/deploy-script.ts
86230
87285
  function resolveExecStart(start, runtime) {
86231
87286
  const bin = runtime === "bun" ? "/usr/local/bin/bun" : runtime === "deno" ? "/usr/local/bin/deno" : "/usr/local/bin/node";
@@ -86237,25 +87292,31 @@ function buildSiteDeployScript(options) {
86237
87292
  siteName,
86238
87293
  slug,
86239
87294
  artifactFetch,
87295
+ releaseId,
86240
87296
  execStart,
86241
87297
  envEntries,
86242
87298
  port,
87299
+ keepReleases = DEFAULT_KEEP_RELEASES,
86243
87300
  preStartCommands = []
86244
87301
  } = options;
86245
- const appDir = options.appDir ?? `/var/www/${siteName}`;
87302
+ const base = options.appDir ?? `/var/www/${siteName}`;
87303
+ const paths = releasePaths(base, releaseId);
86246
87304
  const serviceName = `${slug}-${siteName}.service`;
87305
+ const sharedPaths = [".env"];
86247
87306
  const envFile = formatEnvFile(envEntries);
86248
- const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
87307
+ const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
86249
87308
  return [
86250
87309
  "set -euo pipefail",
86251
87310
  ...artifactFetch,
86252
- `mkdir -p ${appDir}`,
86253
- `find ${appDir} -mindepth 1 -maxdepth 1 ! -name '.env' -exec rm -rf {} +`,
86254
- `tar xzf /tmp/${siteName}-release.tar.gz -C ${appDir}`,
86255
- `cat > ${appDir}/.env <<'TS_CLOUD_ENV_EOF'`,
87311
+ ...buildEnsureReleaseLayout(paths, sharedPaths),
87312
+ `rm -rf ${paths.release}`,
87313
+ `mkdir -p ${paths.release}`,
87314
+ `tar xzf /tmp/${siteName}-release.tar.gz -C ${paths.release}`,
87315
+ `cat > ${paths.shared}/.env <<'TS_CLOUD_ENV_EOF'`,
86256
87316
  envFile,
86257
87317
  "TS_CLOUD_ENV_EOF",
86258
- `chmod 600 ${appDir}/.env`,
87318
+ `chmod 600 ${paths.shared}/.env`,
87319
+ ...buildLinkSharedPaths(paths, sharedPaths),
86259
87320
  ...preStart,
86260
87321
  `cat > /etc/systemd/system/${serviceName} <<'TS_CLOUD_UNIT_EOF'`,
86261
87322
  "[Unit]",
@@ -86264,11 +87325,11 @@ function buildSiteDeployScript(options) {
86264
87325
  "",
86265
87326
  "[Service]",
86266
87327
  "Type=simple",
86267
- `WorkingDirectory=${appDir}`,
87328
+ `WorkingDirectory=${paths.current}`,
86268
87329
  `ExecStart=${execStart}`,
86269
87330
  "Restart=always",
86270
87331
  "RestartSec=5",
86271
- `EnvironmentFile=${appDir}/.env`,
87332
+ `EnvironmentFile=${paths.current}/.env`,
86272
87333
  ...port ? [`Environment=PORT=${port}`] : [],
86273
87334
  "",
86274
87335
  "[Install]",
@@ -86276,21 +87337,27 @@ function buildSiteDeployScript(options) {
86276
87337
  "TS_CLOUD_UNIT_EOF",
86277
87338
  "systemctl daemon-reload",
86278
87339
  `systemctl enable ${serviceName}`,
87340
+ ...buildActivateRelease(paths),
86279
87341
  `systemctl restart ${serviceName}`,
86280
- `systemctl is-active ${serviceName}`
87342
+ `systemctl is-active ${serviceName}`,
87343
+ ...buildPruneReleases(paths, keepReleases)
86281
87344
  ];
86282
87345
  }
86283
87346
  function buildStaticSiteDeployScript(options) {
86284
- const { siteName, artifactFetch, preStartCommands = [] } = options;
86285
- const appDir = options.appDir ?? `/var/www/${siteName}`;
86286
- const preStart = preStartCommands.length > 0 ? [`cd ${appDir}`, ...preStartCommands] : [];
87347
+ const { siteName, artifactFetch, releaseId, keepReleases = DEFAULT_KEEP_RELEASES, preStartCommands = [] } = options;
87348
+ const base = options.appDir ?? `/var/www/${siteName}`;
87349
+ const paths = releasePaths(base, releaseId);
87350
+ const preStart = preStartCommands.length > 0 ? [`cd ${paths.release}`, ...preStartCommands] : [];
86287
87351
  return [
86288
87352
  "set -euo pipefail",
86289
87353
  ...artifactFetch,
86290
- `mkdir -p ${appDir}`,
86291
- `find ${appDir} -mindepth 1 -maxdepth 1 -exec rm -rf {} +`,
86292
- `tar xzf /tmp/${siteName}-release.tar.gz -C ${appDir}`,
86293
- ...preStart
87354
+ ...buildEnsureReleaseLayout(paths, []),
87355
+ `rm -rf ${paths.release}`,
87356
+ `mkdir -p ${paths.release}`,
87357
+ `tar xzf /tmp/${siteName}-release.tar.gz -C ${paths.release}`,
87358
+ ...preStart,
87359
+ ...buildActivateRelease(paths),
87360
+ ...buildPruneReleases(paths, keepReleases)
86294
87361
  ];
86295
87362
  }
86296
87363
  function buildAwsArtifactFetch(bucket, key, region, siteName) {
@@ -86306,6 +87373,95 @@ function buildLocalArtifactFetch(localPath, siteName) {
86306
87373
  // src/drivers/shared/compute-deploy.ts
86307
87374
  await init_dist2();
86308
87375
 
87376
+ // src/deploy/management-dashboard.ts
87377
+ await init_dist2();
87378
+ import { execSync as execSync5 } from "node:child_process";
87379
+ import { existsSync as existsSync21 } from "node:fs";
87380
+ import { tmpdir as tmpdir6 } from "node:os";
87381
+ import { dirname as dirname8, isAbsolute as isAbsolute5, join as join20 } from "node:path";
87382
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
87383
+ var MANAGEMENT_DASHBOARD_SITE = "dashboard";
87384
+ var noopLogger = { info: () => {}, warn: () => {} };
87385
+ function truthy(v) {
87386
+ return v != null && v !== "" && v !== "0" && v.toLowerCase() !== "false";
87387
+ }
87388
+ function resolveUiSource(cwd) {
87389
+ if (existsSync21(join20(cwd, "packages", "ui", "pages")) || existsSync21(join20(cwd, "packages", "ui", "package.json"))) {
87390
+ return { uiRoot: "packages/ui/dist", build: "cd packages/ui && bun install && bun run build" };
87391
+ }
87392
+ const here = dirname8(fileURLToPath4(import.meta.url));
87393
+ const candidates = [
87394
+ join20(here, "ui"),
87395
+ join20(here, "..", "ui"),
87396
+ join20(here, "..", "..", "ui"),
87397
+ join20(here, "..", "dist", "ui")
87398
+ ];
87399
+ for (const dir of candidates) {
87400
+ if (existsSync21(join20(dir, "index.html")) || existsSync21(join20(dir, "serverless.html")))
87401
+ return { uiRoot: dir, build: false };
87402
+ }
87403
+ return null;
87404
+ }
87405
+ function ensureManagementDashboard(config6, options = {}) {
87406
+ const logger4 = options.logger ?? noopLogger;
87407
+ const cwd = options.cwd ?? process.cwd();
87408
+ if (truthy(process.env.TS_CLOUD_UI_DISABLE)) {
87409
+ logger4.info("Management dashboard: skipped (TS_CLOUD_UI_DISABLE set).");
87410
+ return config6;
87411
+ }
87412
+ if (hasManagementDashboardSite(config6))
87413
+ return config6;
87414
+ const ui = resolveUiSource(cwd);
87415
+ if (!ui) {
87416
+ logger4.info("Management dashboard: UI not found (no local ui/ or packaged dist/ui) — skipping auto-deploy.");
87417
+ return config6;
87418
+ }
87419
+ const password = process.env.TS_CLOUD_UI_PASSWORD?.trim() || undefined;
87420
+ const environment = config6.environments && Object.keys(config6.environments)[0];
87421
+ const live = truthy(process.env.TS_CLOUD_UI_LIVE);
87422
+ const port = Number(process.env.TS_CLOUD_UI_PORT) || undefined;
87423
+ const resolved = resolveManagementDashboardSite(config6, environment ?? "production", {
87424
+ uiRoot: ui.uiRoot,
87425
+ build: ui.build,
87426
+ domain: process.env.TS_CLOUD_UI_DOMAIN?.trim() || undefined,
87427
+ username: process.env.TS_CLOUD_UI_USERNAME?.trim() || undefined,
87428
+ password,
87429
+ realm: process.env.TS_CLOUD_UI_REALM?.trim() || undefined,
87430
+ live,
87431
+ port
87432
+ });
87433
+ if (!resolved) {
87434
+ logger4.info("Management dashboard: no domain resolved (set TS_CLOUD_UI_DOMAIN or configure a site domain) — skipping.");
87435
+ return config6;
87436
+ }
87437
+ config6.sites = { ...config6.sites ?? {}, [resolved.name]: resolved.site };
87438
+ logger4.info(`Management dashboard → https://${resolved.site.domain} (${password ? "htpasswd-protected" : "NO AUTH — set TS_CLOUD_UI_PASSWORD"})`);
87439
+ return config6;
87440
+ }
87441
+ function buildManagementDashboardArtifact(site, options) {
87442
+ if (!site?.root)
87443
+ return null;
87444
+ const cwd = options.cwd ?? process.cwd();
87445
+ const logger4 = options.logger ?? noopLogger;
87446
+ try {
87447
+ if (typeof site.build === "string" && site.build.trim()) {
87448
+ logger4.info(`Management dashboard: building UI (${site.build})`);
87449
+ execSync5(site.build, { cwd, stdio: "inherit" });
87450
+ }
87451
+ const root = isAbsolute5(site.root) ? site.root : join20(cwd, site.root);
87452
+ if (!existsSync21(root)) {
87453
+ logger4.warn(`Management dashboard: build output not found at ${root} — skipping dashboard artifact.`);
87454
+ return null;
87455
+ }
87456
+ const tarball = join20(tmpdir6(), `${options.slug}-${MANAGEMENT_DASHBOARD_SITE}-${options.sha}.tar.gz`);
87457
+ execSync5(`tar czf "${tarball}" -C "${root}" .`, { stdio: "inherit" });
87458
+ return tarball;
87459
+ } catch (error2) {
87460
+ logger4.warn(`Management dashboard: failed to build artifact — ${error2?.message ?? error2}. Skipping dashboard deploy.`);
87461
+ return null;
87462
+ }
87463
+ }
87464
+
86309
87465
  // src/drivers/shared/certbot.ts
86310
87466
  function resolveSslProvider(site) {
86311
87467
  if (site.ssl?.provider)
@@ -86427,93 +87583,6 @@ function shellQuote(value) {
86427
87583
  return `'${escaped}'`;
86428
87584
  }
86429
87585
 
86430
- // src/drivers/shared/releases.ts
86431
- var DEFAULT_SHARED_PATHS = ["storage", ".env"];
86432
- var DEFAULT_KEEP_RELEASES = 4;
86433
- var DEFAULT_KEEP_DEPLOY_LOGS = 20;
86434
- function deployMetaDir(base) {
86435
- return `${base.replace(/\/+$/, "")}/.ts-cloud`;
86436
- }
86437
- function deployHistoryPath(base) {
86438
- return `${deployMetaDir(base)}/deploy-history.log`;
86439
- }
86440
- function deployLogPath(base, releaseId) {
86441
- return `${deployMetaDir(base)}/deploys/${releaseId}.log`;
86442
- }
86443
- function releasePaths(base, releaseId) {
86444
- const root = base.replace(/\/+$/, "");
86445
- return {
86446
- base: root,
86447
- releases: `${root}/releases`,
86448
- shared: `${root}/shared`,
86449
- current: `${root}/current`,
86450
- release: `${root}/releases/${releaseId}`
86451
- };
86452
- }
86453
- function isFileSharedPath(p) {
86454
- const name = p.split("/").pop() || p;
86455
- return name.startsWith(".") || /\.[a-z0-9]+$/i.test(name);
86456
- }
86457
- function buildEnsureReleaseLayout(paths, sharedPaths = DEFAULT_SHARED_PATHS) {
86458
- const lines = [
86459
- `mkdir -p ${paths.releases} ${paths.shared}`
86460
- ];
86461
- for (const p of sharedPaths) {
86462
- if (isFileSharedPath(p)) {
86463
- lines.push(`mkdir -p "$(dirname ${paths.shared}/${p})"`, `touch ${paths.shared}/${p}`);
86464
- } else if (p === "storage") {
86465
- lines.push(`mkdir -p ${paths.shared}/storage/app/public`, `mkdir -p ${paths.shared}/storage/framework/cache/data`, `mkdir -p ${paths.shared}/storage/framework/sessions`, `mkdir -p ${paths.shared}/storage/framework/testing`, `mkdir -p ${paths.shared}/storage/framework/views`, `mkdir -p ${paths.shared}/storage/logs`);
86466
- } else {
86467
- lines.push(`mkdir -p ${paths.shared}/${p}`);
86468
- }
86469
- }
86470
- return lines;
86471
- }
86472
- function buildLinkSharedPaths(paths, sharedPaths = DEFAULT_SHARED_PATHS) {
86473
- const lines = [];
86474
- for (const p of sharedPaths) {
86475
- const target = `${paths.shared}/${p}`;
86476
- const link = `${paths.release}/${p}`;
86477
- lines.push(`rm -rf ${link}`, `mkdir -p "$(dirname ${link})"`, `ln -sfn ${target} ${link}`);
86478
- }
86479
- return lines;
86480
- }
86481
- function buildActivateRelease(paths) {
86482
- return [
86483
- `ln -sfn ${paths.release} ${paths.current}.tmp`,
86484
- `mv -Tf ${paths.current}.tmp ${paths.current}`
86485
- ];
86486
- }
86487
- function buildPruneReleases(paths, keep = DEFAULT_KEEP_RELEASES) {
86488
- const n = Math.max(1, keep);
86489
- return [
86490
- `TS_CLOUD_CURRENT=$(readlink -f ${paths.current} 2>/dev/null || true)`,
86491
- `ls -1dt ${paths.releases}/*/ 2>/dev/null | sed 's#/$##' | tail -n +${n + 1} | while read -r TS_CLOUD_OLD; do`,
86492
- ' [ "$(readlink -f "$TS_CLOUD_OLD")" = "$TS_CLOUD_CURRENT" ] || rm -rf "$TS_CLOUD_OLD"',
86493
- "done"
86494
- ];
86495
- }
86496
- function buildDeployHistoryHeader(base, options) {
86497
- const meta = deployMetaDir(base);
86498
- const log4 = deployLogPath(base, options.releaseId);
86499
- const history = deployHistoryPath(base);
86500
- const keepLogs = Math.max(1, options.keepLogs ?? DEFAULT_KEEP_DEPLOY_LOGS);
86501
- const commit = options.commit || "";
86502
- const branch = options.branch || "";
86503
- return [
86504
- `mkdir -p ${meta}/deploys`,
86505
- `exec > >(tee -a ${log4}) 2>&1`,
86506
- `echo "[ts-cloud] deploy ${options.releaseId} commit=${commit} branch=${branch} starting $(date -u +%Y-%m-%dT%H:%M:%SZ)"`,
86507
- "ts_cloud_record_deploy() {",
86508
- " TS_CLOUD_RC=$?",
86509
- ' if [ "$TS_CLOUD_RC" -eq 0 ]; then TS_CLOUD_ST=success; else TS_CLOUD_ST=failed; fi',
86510
- ` 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}`,
86511
- "}",
86512
- "trap ts_cloud_record_deploy EXIT",
86513
- `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`
86514
- ];
86515
- }
86516
-
86517
87586
  // src/drivers/shared/laravel-deploy.ts
86518
87587
  var MACRO_CREATE_RELEASE = "$CREATE_RELEASE";
86519
87588
  var MACRO_ACTIVATE_RELEASE = "$ACTIVATE_RELEASE";
@@ -86837,14 +87906,14 @@ function buildPhpFpmPoolScript(options) {
86837
87906
  }
86838
87907
 
86839
87908
  // src/drivers/shared/compute-deploy.ts
86840
- var noopLogger = {
87909
+ var noopLogger2 = {
86841
87910
  info: () => {},
86842
87911
  warn: () => {},
86843
87912
  error: () => {},
86844
87913
  step: () => {},
86845
87914
  success: () => {}
86846
87915
  };
86847
- async function deploySiteRelease(driver, options, logger4 = noopLogger) {
87916
+ async function deploySiteRelease(driver, options, logger4 = noopLogger2) {
86848
87917
  const {
86849
87918
  config: config6,
86850
87919
  environment,
@@ -86944,11 +88013,13 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86944
88013
  const baseScript = kind === "server-static" ? buildStaticSiteDeployScript({
86945
88014
  siteName,
86946
88015
  artifactFetch,
88016
+ releaseId: sha,
86947
88017
  preStartCommands: site.preStart
86948
88018
  }) : buildSiteDeployScript({
86949
88019
  siteName,
86950
88020
  slug,
86951
88021
  artifactFetch,
88022
+ releaseId: sha,
86952
88023
  execStart: resolveExecStart(site.start, runtime),
86953
88024
  envEntries: site.env || {},
86954
88025
  port: site.port,
@@ -86961,7 +88032,7 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86961
88032
  domain: site.domain,
86962
88033
  aliases: site.aliases,
86963
88034
  type: site.type === "spa" ? "spa" : "static",
86964
- appDir: `/var/www/${siteName}`,
88035
+ appDir: `/var/www/${siteName}/current`,
86965
88036
  webDirectory: "",
86966
88037
  redirects: site.redirects,
86967
88038
  auth: site.auth && site.auth.enabled !== false && site.auth.password ? { username: site.auth.username || "admin", password: site.auth.password, realm: site.auth.realm } : undefined,
@@ -86972,7 +88043,16 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86972
88043
  security: site.security
86973
88044
  }) : [];
86974
88045
  const staticSsl = wantsNginxStatic ? buildSslScript(site) : [];
86975
- const remoteScript = [...baseScript, ...staticVhost, ...staticSsl];
88046
+ const remoteScript = [
88047
+ ...buildDeployHistoryHeader(`/var/www/${siteName}`, {
88048
+ releaseId: sha,
88049
+ commit: sha,
88050
+ branch: site.branch ?? "main"
88051
+ }),
88052
+ ...baseScript,
88053
+ ...staticVhost,
88054
+ ...staticSsl
88055
+ ];
86976
88056
  logger4.step(`Deploying to ${targets.length} target(s)...`);
86977
88057
  const result = await driver.runRemoteDeploy({
86978
88058
  targets,
@@ -86999,8 +88079,21 @@ async function deploySiteRelease(driver, options, logger4 = noopLogger) {
86999
88079
  };
87000
88080
  }
87001
88081
  async function deployAllComputeSites(options) {
87002
- const { config: config6, environment, driver, sha, runtime, tarballForSite, logger: logger4 = noopLogger } = options;
88082
+ const { config: config6, environment, driver, sha, runtime, tarballForSite, logger: logger4 = noopLogger2 } = options;
87003
88083
  const slug = config6.project.slug;
88084
+ const cwd = options.cwd ?? process.cwd();
88085
+ const hadDashboard = hasManagementDashboardSite(config6);
88086
+ ensureManagementDashboard(config6, { cwd, logger: { info: logger4.info, warn: logger4.warn } });
88087
+ const injectedDashboard = !hadDashboard && hasManagementDashboardSite(config6);
88088
+ let dashboardTarball = null;
88089
+ if (injectedDashboard) {
88090
+ dashboardTarball = buildManagementDashboardArtifact(config6.sites?.[MANAGEMENT_DASHBOARD_SITE], { cwd, slug, sha, logger: { info: logger4.info, warn: logger4.warn } });
88091
+ if (!dashboardTarball) {
88092
+ logger4.warn("Management dashboard: no artifact available — skipping dashboard site for this deploy.");
88093
+ if (config6.sites)
88094
+ delete config6.sites[MANAGEMENT_DASHBOARD_SITE];
88095
+ }
88096
+ }
87004
88097
  const sites = config6.sites || {};
87005
88098
  const deployable = Object.entries(sites).filter(([name, site]) => {
87006
88099
  if (!site)
@@ -87010,13 +88103,16 @@ async function deployAllComputeSites(options) {
87010
88103
  logger4.warn(`Site '${name}' targets a bucket — skipping (handled by the static-site path, not compute).`);
87011
88104
  return false;
87012
88105
  }
88106
+ if (kind === "redirect")
88107
+ return false;
87013
88108
  return true;
87014
88109
  });
87015
88110
  if (deployable.length === 0)
87016
88111
  return true;
88112
+ const tarballFor = (siteName) => siteName === MANAGEMENT_DASHBOARD_SITE && dashboardTarball ? dashboardTarball : tarballForSite(siteName);
87017
88113
  for (const [siteName, site] of deployable) {
87018
88114
  logger4.step(`Deploying site: ${siteName}`);
87019
- const localTarballPath = isPhpSite(site) ? undefined : tarballForSite(siteName);
88115
+ const localTarballPath = isPhpSite(site) ? undefined : tarballFor(siteName);
87020
88116
  const result = await deploySiteRelease(driver, {
87021
88117
  config: config6,
87022
88118
  environment,
@@ -87049,7 +88145,7 @@ async function deployAllComputeSites(options) {
87049
88145
  return true;
87050
88146
  }
87051
88147
  async function reloadRpxGateway(options) {
87052
- const { config: config6, environment, driver, logger: logger4 = noopLogger } = options;
88148
+ const { config: config6, environment, driver, logger: logger4 = noopLogger2 } = options;
87053
88149
  const proxy = config6.infrastructure?.compute?.proxy;
87054
88150
  if (proxy?.engine !== "rpx")
87055
88151
  return true;
@@ -87069,7 +88165,7 @@ async function reloadRpxGateway(options) {
87069
88165
  return true;
87070
88166
  }
87071
88167
  logger4.step(`Reloading rpx gateway with ${rpxConfig.proxies.length} route(s)...`);
87072
- const script = buildRpxProvisionScript({ proxy, config: rpxConfig });
88168
+ const script = buildRpxProvisionScript({ proxy, config: rpxConfig, slug: config6.project.slug });
87073
88169
  const result = await driver.runRemoteDeploy({
87074
88170
  targets,
87075
88171
  commands: script,
@@ -87087,6 +88183,2077 @@ async function reloadRpxGateway(options) {
87087
88183
  logger4.success(`rpx gateway reloaded on ${result.instanceCount} target(s)`);
87088
88184
  return true;
87089
88185
  }
88186
+ // src/deploy/ssh-config-editor.ts
88187
+ import { createHash as createHash11 } from "node:crypto";
88188
+ function addSshKeyToCloudConfig(input) {
88189
+ const name = normalizeKeyName(input.name);
88190
+ const publicKey = normalizePublicKey(input.publicKey);
88191
+ const keys = dedupeKeys([...input.existingKeys ?? [], { name, publicKey }]);
88192
+ return updateComputeSshKeys(input.configText, keys);
88193
+ }
88194
+ function removeSshKeyFromCloudConfig(input) {
88195
+ const name = normalizeKeyName(input.name);
88196
+ const keys = (input.existingKeys ?? []).filter((key) => key.name !== name);
88197
+ if ((input.existingKeys ?? []).length === keys.length)
88198
+ throw new Error(`SSH key '${name}' does not exist in compute.sshKeys`);
88199
+ return updateComputeSshKeys(input.configText, keys);
88200
+ }
88201
+ function describeSshKeys(keys = []) {
88202
+ return keys.map((key) => ({
88203
+ name: key.name,
88204
+ publicKey: key.publicKey,
88205
+ type: sshKeyType(key.publicKey),
88206
+ fingerprint: sshFingerprint(key.publicKey),
88207
+ added: "configured"
88208
+ }));
88209
+ }
88210
+ function updateComputeSshKeys(configText, keys) {
88211
+ const compute = findPropertyObject(configText, "compute");
88212
+ const rendered = renderSshKeys(keys);
88213
+ const existing = findPropertyArray(configText, compute.start, compute.end, "sshKeys");
88214
+ if (existing) {
88215
+ let lineStart = existing.propertyStart;
88216
+ while (lineStart > 0 && configText[lineStart - 1] !== `
88217
+ ` && /\s/.test(configText[lineStart - 1]))
88218
+ lineStart--;
88219
+ return `${configText.slice(0, lineStart)}${rendered}${configText.slice(existing.end + 1)}`;
88220
+ }
88221
+ const before = configText.slice(0, compute.end).trimEnd();
88222
+ const after = configText.slice(compute.end);
88223
+ const sep = before.endsWith(",") || before.endsWith("{") ? "" : ",";
88224
+ return `${before}${sep}
88225
+ ${rendered},
88226
+ ${after}`;
88227
+ }
88228
+ function renderSshKeys(keys) {
88229
+ if (keys.length === 0)
88230
+ return " sshKeys: []";
88231
+ const body = keys.map((key) => [
88232
+ " {",
88233
+ ` name: '${escapeString(key.name)}',`,
88234
+ ` publicKey: '${escapeString(normalizePublicKey(key.publicKey))}',`,
88235
+ " },"
88236
+ ].join(`
88237
+ `)).join(`
88238
+ `);
88239
+ return ` sshKeys: [
88240
+ ${body}
88241
+ ]`;
88242
+ }
88243
+ function dedupeKeys(keys) {
88244
+ const seen = new Map;
88245
+ for (const key of keys) {
88246
+ const name = normalizeKeyName(key.name);
88247
+ if (seen.has(name))
88248
+ throw new Error(`SSH key '${name}' already exists in compute.sshKeys`);
88249
+ seen.set(name, { name, publicKey: normalizePublicKey(key.publicKey) });
88250
+ }
88251
+ return [...seen.values()];
88252
+ }
88253
+ function normalizeKeyName(name) {
88254
+ const normalized = name.trim();
88255
+ if (!/^[\w@.+-]{2,80}$/.test(normalized))
88256
+ throw new Error("SSH key name must be 2-80 characters and use letters, numbers, @, ., +, _, or -.");
88257
+ return normalized;
88258
+ }
88259
+ function normalizePublicKey(publicKey) {
88260
+ const normalized = publicKey.trim().replace(/\s+/g, " ");
88261
+ const [type, body] = normalized.split(" ");
88262
+ if (!/^(?:ssh-(?:ed25519|rsa)|ecdsa-sha2-nistp(?:256|384|521))$/.test(type ?? "") || !body)
88263
+ throw new Error("Public key must be an OpenSSH public key, e.g. ssh-ed25519 AAAA... name@host.");
88264
+ return normalized;
88265
+ }
88266
+ function sshKeyType(publicKey) {
88267
+ return publicKey.trim().split(/\s+/)[0] || "ssh";
88268
+ }
88269
+ function sshFingerprint(publicKey) {
88270
+ const body = publicKey.trim().split(/\s+/)[1];
88271
+ if (!body)
88272
+ return "-";
88273
+ try {
88274
+ return `SHA256:${createHash11("sha256").update(Buffer.from(body, "base64")).digest("base64").replace(/=+$/, "")}`;
88275
+ } catch {
88276
+ return "-";
88277
+ }
88278
+ }
88279
+ function escapeString(value) {
88280
+ return value.replace(/\\/g, "\\\\").replaceAll(String.fromCharCode(39), "\\'");
88281
+ }
88282
+ function findPropertyObject(configText, property) {
88283
+ const match = new RegExp(`\\b${property}\\s*:\\s*{`, "g").exec(configText);
88284
+ if (!match)
88285
+ throw new Error(`Could not find ${property}: { ... } in cloud config.`);
88286
+ const start = configText.indexOf("{", match.index);
88287
+ return { propertyStart: match.index, start, end: findMatching(configText, start, "{", "}") };
88288
+ }
88289
+ function findPropertyArray(configText, start, end, property) {
88290
+ const body = configText.slice(start + 1, end);
88291
+ const match = new RegExp(`\\b${property}\\s*:\\s*\\[`, "g").exec(body);
88292
+ if (!match)
88293
+ return null;
88294
+ const propertyStart = start + 1 + match.index;
88295
+ const arrayStart = configText.indexOf("[", propertyStart);
88296
+ return { propertyStart, start: arrayStart, end: findMatching(configText, arrayStart, "[", "]") };
88297
+ }
88298
+ function findMatching(text, start, open, close) {
88299
+ let depth = 0;
88300
+ let quote;
88301
+ let escaped = false;
88302
+ let lineComment = false;
88303
+ let blockComment = false;
88304
+ for (let index = start;index < text.length; index++) {
88305
+ const char = text[index];
88306
+ const next = text[index + 1];
88307
+ if (lineComment) {
88308
+ if (char === `
88309
+ `)
88310
+ lineComment = false;
88311
+ continue;
88312
+ }
88313
+ if (blockComment) {
88314
+ if (char === "*" && next === "/") {
88315
+ blockComment = false;
88316
+ index++;
88317
+ }
88318
+ continue;
88319
+ }
88320
+ if (quote) {
88321
+ if (escaped) {
88322
+ escaped = false;
88323
+ continue;
88324
+ }
88325
+ if (char === "\\") {
88326
+ escaped = true;
88327
+ continue;
88328
+ }
88329
+ if (char === quote)
88330
+ quote = undefined;
88331
+ continue;
88332
+ }
88333
+ if (char === "/" && next === "/") {
88334
+ lineComment = true;
88335
+ index++;
88336
+ continue;
88337
+ }
88338
+ if (char === "/" && next === "*") {
88339
+ blockComment = true;
88340
+ index++;
88341
+ continue;
88342
+ }
88343
+ if (char === '"' || char === "'" || char === "`") {
88344
+ quote = char;
88345
+ continue;
88346
+ }
88347
+ if (char === open)
88348
+ depth++;
88349
+ if (char === close) {
88350
+ depth--;
88351
+ if (depth === 0)
88352
+ return index;
88353
+ }
88354
+ }
88355
+ throw new Error(`Could not find matching ${close} in cloud config.`);
88356
+ }
88357
+
88358
+ // src/deploy/dashboard-data-server.ts
88359
+ var PROBED_SERVICES = ["rpx-gateway", "nginx", "php8.3-fpm", "php8.2-fpm", "mysql", "mariadb", "postgresql", "redis", "redis-server", "meilisearch"];
88360
+ var UNKNOWN = "-";
88361
+ function metricsScript() {
88362
+ return [
88363
+ "set +e",
88364
+ 'echo "CPUS=$(nproc 2>/dev/null || echo 1)"',
88365
+ `echo "LOAD=$(cut -d' ' -f1 /proc/loadavg 2>/dev/null || echo 0)"`,
88366
+ `echo "MEMTOTAL=$(free -m 2>/dev/null | awk '/^Mem:/{print $2}')"`,
88367
+ `echo "MEMUSED=$(free -m 2>/dev/null | awk '/^Mem:/{print $3}')"`,
88368
+ `echo "DISKPCT=$(df -P / 2>/dev/null | awk 'NR==2{gsub("%","",$5);print $5}')"`,
88369
+ `echo "DISKUSEDG=$(df -BG / 2>/dev/null | awk 'NR==2{gsub("G","",$3);print $3}')"`,
88370
+ `echo "DISKTOTG=$(df -BG / 2>/dev/null | awk 'NR==2{gsub("G","",$2);print $2}')"`,
88371
+ `echo "UPTIME=$(uptime -p 2>/dev/null | sed 's/^up //' || echo unknown)"`,
88372
+ 'echo "OS=$(. /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"',
88373
+ `for s in ${PROBED_SERVICES.join(" ")}; do st=$(systemctl is-active "$s" 2>/dev/null); if [ -n "$st" ] && [ "$st" != "inactive" ] && [ "$st" != "unknown" ]; then mem=$(systemctl show "$s" -p MemoryCurrent --value 2>/dev/null); en=$(systemctl is-enabled "$s" 2>/dev/null); since=$(systemctl show "$s" -p ActiveEnterTimestamp --value 2>/dev/null); echo "SVC=$s=$st=$mem=$en=$since"; fi; done`,
88374
+ "true"
88375
+ ];
88376
+ }
88377
+ function parseBlock(output) {
88378
+ const kv = { services: [] };
88379
+ for (const line of output.split(`
88380
+ `)) {
88381
+ const l = line.trim();
88382
+ if (l.startsWith("SVC=")) {
88383
+ const [, name, status, memBytes, enabled2, since] = /^SVC=([^=]+)=([^=]+)(?:=([^=]*)(?:=([^=]*)(?:=(.*))?)?)?$/.exec(l) ?? [];
88384
+ if (name) {
88385
+ kv.services.push({
88386
+ name,
88387
+ status,
88388
+ memBytes: Number(memBytes || 0),
88389
+ enabled: enabled2 || UNKNOWN,
88390
+ since: since || UNKNOWN
88391
+ });
88392
+ }
88393
+ } else {
88394
+ const eq = l.indexOf("=");
88395
+ if (eq > 0)
88396
+ kv[l.slice(0, eq)] = l.slice(eq + 1);
88397
+ }
88398
+ }
88399
+ return kv;
88400
+ }
88401
+ function configuredServices(config6) {
88402
+ const compute = config6.infrastructure?.compute;
88403
+ const services = [];
88404
+ if (compute?.webServer === "rpx" || compute?.proxy?.engine === "rpx")
88405
+ services.push({ name: "rpx-gateway", status: "configured" });
88406
+ else if (compute?.webServer === "nginx" || compute?.webServer == null)
88407
+ services.push({ name: "ts-cloud-nginx", status: "configured" });
88408
+ const phpVersions = compute?.php?.versions ?? (compute?.runtime === "php" ? ["8.3"] : []);
88409
+ for (const version2 of phpVersions)
88410
+ services.push({ name: `php${version2}-fpm`, status: "configured" });
88411
+ const managed = compute?.managedServices ?? {};
88412
+ const serviceNames = [
88413
+ ["mysql", "mysql"],
88414
+ ["mariadb", "mariadb"],
88415
+ ["postgres", "postgresql"],
88416
+ ["redis", "redis"],
88417
+ ["memcached", "memcached"],
88418
+ ["meilisearch", "meilisearch"]
88419
+ ];
88420
+ for (const [key, name] of serviceNames) {
88421
+ if (managed[key])
88422
+ services.push({ name, status: "configured" });
88423
+ }
88424
+ return services;
88425
+ }
88426
+ function configuredBackup(config6) {
88427
+ const backup = config6.infrastructure?.compute?.backups;
88428
+ const enabled2 = !!backup?.enabled;
88429
+ const schedule = backup?.schedule ?? "0 2 * * *";
88430
+ return {
88431
+ enabled: enabled2,
88432
+ schedule,
88433
+ destination: enabled2 ? backup?.bucket ?? "local" : "off",
88434
+ retention: enabled2 ? backup?.retentionCount ?? 5 : 0,
88435
+ last: enabled2 ? "pending first run" : "not configured",
88436
+ size: enabled2 ? UNKNOWN : "0 MB"
88437
+ };
88438
+ }
88439
+ function configuredProvider(config6) {
88440
+ return config6.infrastructure?.compute?.provider ?? config6.cloud?.provider ?? config6.provider ?? "aws";
88441
+ }
88442
+ function configuredRegion(config6) {
88443
+ const provider = configuredProvider(config6);
88444
+ if (provider === "hetzner") {
88445
+ return config6.hetzner?.location ?? process.env.HCLOUD_LOCATION ?? process.env.HETZNER_LOCATION ?? "fsn1";
88446
+ }
88447
+ return config6.project.region ?? "us-east-1";
88448
+ }
88449
+ function loadLocalState(config6, environment) {
88450
+ const statePath = join21(process.cwd(), ".ts-cloud", "state", `${config6.project.slug}-${environment}.json`);
88451
+ if (!existsSync24(statePath))
88452
+ return null;
88453
+ try {
88454
+ return JSON.parse(readFileSync17(statePath, "utf8"));
88455
+ } catch {
88456
+ return null;
88457
+ }
88458
+ }
88459
+ function configuredWorkers(config6) {
88460
+ const sites = config6.sites ?? {};
88461
+ const workers = [];
88462
+ for (const [siteName, site] of Object.entries(sites)) {
88463
+ const queues = site.queues ?? site.workers;
88464
+ if (Array.isArray(queues)) {
88465
+ for (const queue of queues) {
88466
+ if (typeof queue === "string")
88467
+ workers.push({ name: `${siteName}:${queue}`, site: siteName, processes: 1, status: "configured" });
88468
+ else if (queue?.name)
88469
+ workers.push({ name: `${siteName}:${queue.name}`, site: siteName, processes: queue.processes ?? 1, status: "configured" });
88470
+ }
88471
+ }
88472
+ }
88473
+ return workers;
88474
+ }
88475
+ function normalizeSitePath(path) {
88476
+ if (!path || path === "")
88477
+ return "/";
88478
+ return path.startsWith("/") ? path : `/${path}`;
88479
+ }
88480
+ function siteRoute(site) {
88481
+ const path = normalizeSitePath(site.path);
88482
+ if (!site.domain)
88483
+ return { route: "internal" };
88484
+ return {
88485
+ route: path === "/" ? site.domain : `${site.domain}${path}`,
88486
+ href: `https://${site.domain}${path === "/" ? "" : path}`
88487
+ };
88488
+ }
88489
+ function siteKindLabel(name, site) {
88490
+ const kind = resolveSiteKind(site);
88491
+ const build = String(site.build ?? "").toLowerCase();
88492
+ const start = String(site.start ?? "").toLowerCase();
88493
+ if (name === "main" || start.includes("buddy/src/cli.ts serve"))
88494
+ return "stacks";
88495
+ if (name === "api" || start.includes("/serve/api"))
88496
+ return "api";
88497
+ if (build.includes("buildblog"))
88498
+ return "bunpress blog";
88499
+ if (build.includes("bunpress"))
88500
+ return "bunpress";
88501
+ if (build.includes("site:build") || build.includes("stx"))
88502
+ return "stx static";
88503
+ if (kind === "server-static")
88504
+ return site.spa ? "spa" : "static";
88505
+ if (kind === "server-app")
88506
+ return "app";
88507
+ if (kind === "server-php")
88508
+ return site.type ?? "php";
88509
+ return "bucket";
88510
+ }
88511
+ function siteRuntime(site) {
88512
+ const kind = resolveSiteKind(site);
88513
+ const command = `${site.start ?? ""} ${site.build ?? ""}`.toLowerCase();
88514
+ if (kind === "server-static")
88515
+ return command.includes("bunpress") || command.includes("bun ") || command.includes("bunx ") ? "static/bun" : "static";
88516
+ if (kind === "server-app")
88517
+ return command.includes("bun") ? "bun" : "node";
88518
+ if (kind === "server-php")
88519
+ return `php ${site.php ?? site.phpVersion ?? "8.3"}`;
88520
+ return "static";
88521
+ }
88522
+ function siteDeployLabel(site) {
88523
+ const kind = resolveSiteKind(site);
88524
+ if (kind === "server-app")
88525
+ return "service";
88526
+ if (kind === "server-static")
88527
+ return "server static";
88528
+ if (kind === "server-php")
88529
+ return "php release";
88530
+ return "bucket";
88531
+ }
88532
+ function configuredSites(config6) {
88533
+ const seenRoutes = new Map;
88534
+ return Object.entries(config6.sites ?? {}).map(([name, site]) => {
88535
+ const path = normalizeSitePath(site.path);
88536
+ const { route, href } = siteRoute(site);
88537
+ const routeKey = site.domain ? `${site.domain}${path}` : "";
88538
+ const shadowedBy = routeKey ? seenRoutes.get(routeKey) : undefined;
88539
+ if (routeKey && !shadowedBy)
88540
+ seenRoutes.set(routeKey, name);
88541
+ return {
88542
+ name,
88543
+ route,
88544
+ href,
88545
+ domain: site.domain ?? "internal",
88546
+ path,
88547
+ kind: siteKindLabel(name, site),
88548
+ type: siteKindLabel(name, site),
88549
+ runtime: siteRuntime(site),
88550
+ deploy: siteDeployLabel(site),
88551
+ tls: site.domain ? site.ssl === false ? "http" : "https" : "loopback",
88552
+ ssl: site.domain ? site.ssl !== false : false,
88553
+ status: shadowedBy ? "shadowed" : "live",
88554
+ shadowedBy,
88555
+ lastDeploy: UNKNOWN
88556
+ };
88557
+ });
88558
+ }
88559
+ function shellSingleQuote(value) {
88560
+ return `'${value.replaceAll("'", "'\\''")}'`;
88561
+ }
88562
+ function sedReplacement(value) {
88563
+ return value.replace(/[\\&/]/g, "\\$&");
88564
+ }
88565
+ function deployHistoryScript(siteNames) {
88566
+ if (siteNames.length === 0)
88567
+ return ["true"];
88568
+ return [
88569
+ "set +e",
88570
+ ...siteNames.map((siteName) => {
88571
+ const historyPath = shellSingleQuote(`/var/www/${siteName}/.ts-cloud/deploy-history.log`);
88572
+ const prefix = sedReplacement(siteName);
88573
+ return `TS_CLOUD_HISTORY=${historyPath}; { [ -f "$TS_CLOUD_HISTORY" ] && tail -n 24 "$TS_CLOUD_HISTORY" | sed "s/^/DEPLOY=${prefix}\\t/"; } || true`;
88574
+ }),
88575
+ "true"
88576
+ ];
88577
+ }
88578
+ function siteDomains(config6) {
88579
+ return [...new Set(Object.values(config6.sites ?? {}).map((site) => site.domain).filter((domain) => typeof domain === "string" && domain.length > 0))];
88580
+ }
88581
+ function securityScript(config6) {
88582
+ const domains = siteDomains(config6);
88583
+ return [
88584
+ "set +e",
88585
+ 'if command -v ss >/dev/null 2>&1; then ss -H -lntup 2>/dev/null | while read -r proto _state _recv _send local _peer rest; do [ -n "$proto" ] && [ -n "$local" ] && printf "PORT=%s\\t%s\\t%s\\n" "$proto" "$local" "$rest"; done; fi',
88586
+ 'if command -v ufw >/dev/null 2>&1; then ufw status numbered 2>/dev/null | sed "s/^/FIREWALL=/"; else echo "FIREWALL=ufw unavailable"; fi',
88587
+ 'journalctl _COMM=sshd --no-pager -n 30 -o short-iso 2>/dev/null | sed "s/^/AUTH=/" || true',
88588
+ ...domains.map((domain) => {
88589
+ const quoted = shellSingleQuote(domain);
88590
+ return `TS_CLOUD_DOMAIN=${quoted}; TS_CLOUD_EXPIRY=$(echo | timeout 8 openssl s_client -servername "$TS_CLOUD_DOMAIN" -connect "$TS_CLOUD_DOMAIN:443" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2-); [ -n "$TS_CLOUD_EXPIRY" ] && printf "CERT=%s\\t%s\\n" "$TS_CLOUD_DOMAIN" "$TS_CLOUD_EXPIRY" || printf "CERT=%s\\tunavailable\\n" "$TS_CLOUD_DOMAIN"`;
88591
+ }),
88592
+ "true"
88593
+ ];
88594
+ }
88595
+ function serverLogSources(config6) {
88596
+ const byLabel = new Map;
88597
+ const add = (pattern, label) => {
88598
+ if (pattern && label && !byLabel.has(label))
88599
+ byLabel.set(label, { pattern, label });
88600
+ };
88601
+ const compute = config6.infrastructure?.compute;
88602
+ const slug = config6.project.slug;
88603
+ if (compute?.webServer === "rpx" || compute?.proxy?.engine === "rpx")
88604
+ add("rpx-gateway", "rpx-gateway");
88605
+ else
88606
+ add("nginx", "nginx");
88607
+ for (const svc of configuredServices(config6))
88608
+ add(svc.name, svc.name);
88609
+ for (const [siteName, site] of Object.entries(config6.sites ?? {})) {
88610
+ if (resolveSiteKind(site) !== "server-app")
88611
+ continue;
88612
+ add(`${slug}-${siteName}`, `${slug}-${siteName}`);
88613
+ if (Array.isArray(site.queues ?? site.workers) && (site.queues ?? site.workers).length)
88614
+ add(`${slug}-${siteName}-queue-*`, `${slug}-${siteName}-queues`);
88615
+ if (Array.isArray(site.daemons) && site.daemons.length)
88616
+ add(`${slug}-${siteName}-daemon-*`, `${slug}-${siteName}-daemons`);
88617
+ }
88618
+ return [...byLabel.values()];
88619
+ }
88620
+ function serverLogsScript(config6) {
88621
+ const sources = serverLogSources(config6);
88622
+ if (sources.length === 0)
88623
+ return ["true"];
88624
+ return [
88625
+ "set +e",
88626
+ ...sources.map((source) => {
88627
+ const quoted = shellSingleQuote(source.pattern);
88628
+ const prefix = sedReplacement(source.label);
88629
+ return `journalctl -u ${quoted} --no-pager -n 150 -o short-iso 2>/dev/null | sed "s/^/LOG=${prefix}\\t/" || true`;
88630
+ }),
88631
+ `[ -f /var/log/ts-cloud-backup.log ] && tail -n 60 /var/log/ts-cloud-backup.log 2>/dev/null | sed "s/^/LOG=backups\\t/" || true`,
88632
+ "true"
88633
+ ];
88634
+ }
88635
+ function relativeTime(iso) {
88636
+ const date = new Date(iso);
88637
+ if (Number.isNaN(date.getTime()))
88638
+ return iso || UNKNOWN;
88639
+ const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000));
88640
+ if (seconds < 60)
88641
+ return `${seconds}s ago`;
88642
+ const minutes = Math.round(seconds / 60);
88643
+ if (minutes < 60)
88644
+ return `${minutes}m ago`;
88645
+ const hours = Math.round(minutes / 60);
88646
+ if (hours < 48)
88647
+ return `${hours}h ago`;
88648
+ const days = Math.round(hours / 24);
88649
+ return `${days}d ago`;
88650
+ }
88651
+ function inferLogLevel(message) {
88652
+ if (/(?:error|failed|panic|fatal|exception|denied|unhealthy)/i.test(message))
88653
+ return "error";
88654
+ if (/(?:warn|warning|retry|restart|deprecated|timeout)/i.test(message))
88655
+ return "warn";
88656
+ return "info";
88657
+ }
88658
+ function parseServerLogs(output) {
88659
+ const records = [];
88660
+ for (const rawLine of output.split(`
88661
+ `)) {
88662
+ const line = rawLine.trim();
88663
+ if (!line.startsWith("LOG="))
88664
+ continue;
88665
+ const tab = line.indexOf("\t");
88666
+ if (tab < 0)
88667
+ continue;
88668
+ const source = line.slice(4, tab);
88669
+ const raw = line.slice(tab + 1);
88670
+ const match = /^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+(\S+)\s+(.*)$/.exec(raw);
88671
+ const timestamp = match?.[1] ?? "";
88672
+ const host = match?.[2] ?? "";
88673
+ const message = (match?.[3] ?? raw).trim();
88674
+ if (!message)
88675
+ continue;
88676
+ records.push({
88677
+ source,
88678
+ timestamp,
88679
+ when: timestamp ? relativeTime(timestamp) : UNKNOWN,
88680
+ host,
88681
+ message,
88682
+ level: inferLogLevel(message)
88683
+ });
88684
+ }
88685
+ return records.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp))).slice(0, 600);
88686
+ }
88687
+ function listenerExposure(listen) {
88688
+ if (/^(?:127\.|localhost:|\[?::1\]?)/.test(listen))
88689
+ return "loopback";
88690
+ if (/^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|\[?f[cd][0-9a-f:])/i.test(listen))
88691
+ return "private";
88692
+ return "public";
88693
+ }
88694
+ function portTone(listen) {
88695
+ const exposure = listenerExposure(listen);
88696
+ if (exposure !== "public")
88697
+ return "ok";
88698
+ if (/:(?:22|80|443)$/.test(listen) || /^\*:(?:22|80|443)$/.test(listen))
88699
+ return "ok";
88700
+ return "warn";
88701
+ }
88702
+ function certStatus(daysRemaining) {
88703
+ if (daysRemaining == null)
88704
+ return "warn";
88705
+ if (daysRemaining < 8)
88706
+ return "bad";
88707
+ if (daysRemaining < 30)
88708
+ return "warn";
88709
+ return "ok";
88710
+ }
88711
+ function parseServerSecurity(output) {
88712
+ const ports = [];
88713
+ const firewallLines = [];
88714
+ const authEvents = [];
88715
+ const tlsCertificates = [];
88716
+ for (const rawLine of output.split(`
88717
+ `)) {
88718
+ const line = rawLine.trim();
88719
+ if (line.startsWith("PORT=")) {
88720
+ const [proto = "", listen = "", process21 = ""] = line.slice(5).split("\t");
88721
+ if (proto && listen) {
88722
+ ports.push({
88723
+ proto,
88724
+ listen,
88725
+ processName: process21 || UNKNOWN,
88726
+ exposure: listenerExposure(listen),
88727
+ tone: portTone(listen)
88728
+ });
88729
+ }
88730
+ } else if (line.startsWith("FIREWALL=")) {
88731
+ const value = line.slice(9).trim();
88732
+ if (value)
88733
+ firewallLines.push(value);
88734
+ } else if (line.startsWith("AUTH=")) {
88735
+ const raw = line.slice(5);
88736
+ const match = /^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+(\S+)\s+(.*)$/.exec(raw);
88737
+ const message = (match?.[3] ?? raw).trim();
88738
+ if (message) {
88739
+ authEvents.push({
88740
+ timestamp: match?.[1] ?? "",
88741
+ when: match?.[1] ? relativeTime(match[1]) : UNKNOWN,
88742
+ host: match?.[2] ?? "",
88743
+ message,
88744
+ level: inferLogLevel(message)
88745
+ });
88746
+ }
88747
+ } else if (line.startsWith("CERT=")) {
88748
+ const [domain = "", expiresRaw = ""] = line.slice(5).split("\t");
88749
+ if (!domain)
88750
+ continue;
88751
+ const expiresAt = new Date(expiresRaw);
88752
+ const daysRemaining = Number.isNaN(expiresAt.getTime()) ? null : Math.ceil((expiresAt.getTime() - Date.now()) / 86400000);
88753
+ tlsCertificates.push({
88754
+ domain,
88755
+ expires: Number.isNaN(expiresAt.getTime()) ? expiresRaw || UNKNOWN : expiresAt.toISOString().slice(0, 10),
88756
+ daysRemaining,
88757
+ status: certStatus(daysRemaining)
88758
+ });
88759
+ }
88760
+ }
88761
+ const firewallEnabled = firewallLines.some((line) => /Status:\s*active/i.test(line));
88762
+ const firewallUnavailable = firewallLines.some((line) => /unavailable/i.test(line));
88763
+ return {
88764
+ ports: ports.slice(0, 80),
88765
+ firewall: {
88766
+ status: firewallEnabled ? "active" : firewallUnavailable ? "unavailable" : "inactive",
88767
+ summary: firewallEnabled ? "ufw active" : firewallUnavailable ? "ufw unavailable" : "ufw inactive or not configured",
88768
+ rules: firewallLines.filter((line) => line && !/^Status:/i.test(line)).slice(0, 60)
88769
+ },
88770
+ tlsCertificates,
88771
+ authEvents: authEvents.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp))).slice(0, 30)
88772
+ };
88773
+ }
88774
+ function configuredSecurity(config6) {
88775
+ const compute = config6.infrastructure?.compute;
88776
+ const firewall = compute?.firewall ?? {};
88777
+ const allowedPorts = [...new Set([22, 80, 443, ...firewall.allowedPorts ?? []])];
88778
+ const domains = siteDomains(config6);
88779
+ return {
88780
+ ports: allowedPorts.map((port) => ({
88781
+ proto: "tcp",
88782
+ listen: `0.0.0.0:${port}`,
88783
+ processName: "configured firewall",
88784
+ exposure: "public",
88785
+ tone: [22, 80, 443].includes(Number(port)) ? "ok" : "warn"
88786
+ })),
88787
+ firewall: {
88788
+ status: firewall.enabled === false ? "disabled" : "configured",
88789
+ summary: firewall.enabled === false ? "host firewall disabled in config" : "host firewall configured declaratively",
88790
+ rules: allowedPorts.map((port) => `ALLOW ${port}/tcp`)
88791
+ },
88792
+ tlsCertificates: domains.map((domain) => ({
88793
+ domain,
88794
+ expires: UNKNOWN,
88795
+ daysRemaining: null,
88796
+ status: "warn"
88797
+ })),
88798
+ authEvents: []
88799
+ };
88800
+ }
88801
+ function diagnosticChecks(config6, data) {
88802
+ const security = data.security ?? configuredSecurity(config6);
88803
+ const live = !!data._serverReachable && !data.metricsUnavailable;
88804
+ const sites = data.sites ?? [];
88805
+ const shadowed = sites.filter((site) => site.status === "shadowed");
88806
+ const failedServices = (data.services ?? []).filter((service) => ["failed", "stopped"].includes(service.status));
88807
+ const expiringCerts = (security.tlsCertificates ?? []).filter((cert) => cert.status !== "ok");
88808
+ const sshKeys = data.sshKeys ?? [];
88809
+ return [
88810
+ {
88811
+ name: "Live server probe",
88812
+ status: live ? "pass" : "warn",
88813
+ detail: live ? "Metrics and remote checks are coming from the compute box." : "The dashboard is rendering config/state data until the box probe succeeds."
88814
+ },
88815
+ {
88816
+ name: "Managed services",
88817
+ status: failedServices.length ? "fail" : "pass",
88818
+ detail: failedServices.length ? `${failedServices.length} service(s) need attention.` : `${(data.services ?? []).length} service(s) reported healthy or configured.`
88819
+ },
88820
+ {
88821
+ name: "Route conflicts",
88822
+ status: shadowed.length ? "warn" : "pass",
88823
+ detail: shadowed.length ? `${shadowed.map((site) => site.name).join(", ")} is shadowed by an earlier route.` : `${sites.length} site route(s) are unshadowed.`
88824
+ },
88825
+ {
88826
+ name: "SSH access",
88827
+ status: sshKeys.length ? "pass" : "warn",
88828
+ detail: sshKeys.length ? `${sshKeys.length} declarative authorized key(s) configured.` : "No declarative SSH keys are configured."
88829
+ },
88830
+ {
88831
+ name: "Firewall",
88832
+ status: ["active", "configured"].includes(security.firewall?.status) ? "pass" : "warn",
88833
+ detail: security.firewall?.summary ?? "Firewall status unavailable."
88834
+ },
88835
+ {
88836
+ name: "TLS certificates",
88837
+ status: expiringCerts.length ? "warn" : "pass",
88838
+ detail: expiringCerts.length ? `${expiringCerts.length} certificate(s) need renewal visibility.` : `${(security.tlsCertificates ?? []).length} certificate(s) look healthy.`
88839
+ }
88840
+ ];
88841
+ }
88842
+ function activityFeed(data) {
88843
+ const activity = [];
88844
+ for (const deploy of data.serverDeploymentsDetail ?? data.serverDeployments ?? []) {
88845
+ activity.push({
88846
+ type: "deploy",
88847
+ tone: deploy.status === "failed" ? "bad" : "ok",
88848
+ title: `${deploy.site} deployed ${deploy.sha}`,
88849
+ detail: `${deploy.branch} · ${deploy.status}`,
88850
+ when: deploy.when,
88851
+ timestamp: deploy.timestamp
88852
+ });
88853
+ }
88854
+ for (const log4 of data.serverLogs ?? []) {
88855
+ if (log4.level === "error" || log4.level === "warn") {
88856
+ activity.push({
88857
+ type: "log",
88858
+ tone: log4.level === "error" ? "bad" : "warn",
88859
+ title: `${log4.source} ${log4.level}`,
88860
+ detail: log4.message,
88861
+ when: log4.when,
88862
+ timestamp: log4.timestamp
88863
+ });
88864
+ }
88865
+ }
88866
+ for (const key of data.sshKeys ?? []) {
88867
+ activity.push({
88868
+ type: "ssh",
88869
+ tone: "ok",
88870
+ title: `${key.name} authorized`,
88871
+ detail: key.fingerprint,
88872
+ when: key.added ?? UNKNOWN,
88873
+ timestamp: ""
88874
+ });
88875
+ }
88876
+ return activity.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp))).slice(0, 80);
88877
+ }
88878
+ function parseDeployHistory(output, sites = {}) {
88879
+ const records = [];
88880
+ for (const rawLine of output.split(`
88881
+ `)) {
88882
+ const line = rawLine.trim();
88883
+ if (!line.startsWith("DEPLOY="))
88884
+ continue;
88885
+ const parts = line.split("\t");
88886
+ const site = parts[0]?.replace(/^DEPLOY=/, "") ?? "";
88887
+ const [timestamp, releaseId, commit, status, rcPart] = parts.slice(1);
88888
+ if (!site || !timestamp || !releaseId)
88889
+ continue;
88890
+ const siteConfig = sites[site] ?? {};
88891
+ const kind = resolveSiteKind(siteConfig);
88892
+ const sha = (commit || releaseId).slice(0, 7);
88893
+ records.push({
88894
+ sha,
88895
+ release: releaseId,
88896
+ commit: commit || releaseId,
88897
+ site,
88898
+ branch: siteConfig.branch ?? (kind === "server-static" ? "build artifact" : "main"),
88899
+ status: status || "unknown",
88900
+ when: relativeTime(timestamp),
88901
+ timestamp,
88902
+ took: "-",
88903
+ by: "ts-cloud",
88904
+ rc: rcPart?.replace(/^rc=/, "") ?? "",
88905
+ steps: kind === "server-static" ? ["upload artifact", "publish static files"] : ["upload artifact", "restart service"]
88906
+ });
88907
+ }
88908
+ return records.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp)));
88909
+ }
88910
+ function resolveConfigOnlyServerDashboardData(config6, environment) {
88911
+ const state = loadLocalState(config6, environment);
88912
+ const services = configuredServices(config6);
88913
+ const sites = configuredSites(config6);
88914
+ const diskSize = Math.max(1, Number(config6.infrastructure?.compute?.disk?.size ?? 1));
88915
+ const out = {
88916
+ server: {
88917
+ name: state?.serverName ?? `${config6.project.slug}-${environment}-app`,
88918
+ provider: state?.provider ?? configuredProvider(config6),
88919
+ region: configuredRegion(config6),
88920
+ ip: state?.publicIp ?? UNKNOWN,
88921
+ os: "Linux",
88922
+ uptime: UNKNOWN,
88923
+ probeStatus: "unavailable"
88924
+ },
88925
+ systemMetrics: {
88926
+ load: 0,
88927
+ cpus: Math.max(1, Number(config6.infrastructure?.compute?.instances ?? 1)),
88928
+ memUsedMb: 0,
88929
+ memTotalMb: 0,
88930
+ diskUsedPct: 0,
88931
+ diskUsedGb: 0,
88932
+ diskTotalGb: diskSize
88933
+ },
88934
+ metricsUnavailable: true,
88935
+ services,
88936
+ servicesDetail: services.map((s) => ({ ...s, since: UNKNOWN, memMb: 0, auto: true })),
88937
+ backup: configuredBackup(config6),
88938
+ backupHistory: [],
88939
+ workers: configuredWorkers(config6),
88940
+ serverScheduler: { enabled: false, lastRun: "not configured" },
88941
+ serverDeployments: [],
88942
+ serverDeploymentsDetail: [],
88943
+ deploymentsEmptyReason: "No deployment history has been recorded yet. Future server deploys will write /var/www/<site>/.ts-cloud/deploy-history.log.",
88944
+ serverLogs: [],
88945
+ serverLogsEmptyReason: "Live server logs are unavailable until the dashboard can reach the compute box.",
88946
+ security: configuredSecurity(config6),
88947
+ securityEmptyReason: "Live security checks are unavailable until the dashboard can reach the compute box.",
88948
+ sites,
88949
+ sitesDetail: sites.map((s) => {
88950
+ const site = config6.sites?.[s.name] ?? {};
88951
+ const kind = resolveSiteKind(site);
88952
+ return {
88953
+ ...s,
88954
+ root: kind === "server-static" ? `/var/www/${s.name}` : `/var/www/${s.name}/current`,
88955
+ branch: kind === "server-static" ? "build artifact" : "main",
88956
+ build: site.build,
88957
+ envKeys: Object.keys(site.env ?? site.environment ?? {})
88958
+ };
88959
+ }),
88960
+ sshKeys: describeSshKeys(config6.infrastructure?.compute?.sshKeys ?? []),
88961
+ _serverReachable: false,
88962
+ _metricsStatus: "unavailable"
88963
+ };
88964
+ out.diagnostics = diagnosticChecks(config6, out);
88965
+ out.activity = activityFeed(out);
88966
+ return out;
88967
+ }
88968
+ async function resolveServerDashboardData(config6, environment) {
88969
+ if (!config6.infrastructure?.compute)
88970
+ return null;
88971
+ let driver = null;
88972
+ try {
88973
+ driver = createCloudDriver({ config: config6 });
88974
+ } catch {
88975
+ driver = null;
88976
+ }
88977
+ let parsed = null;
88978
+ let instanceCount = 0;
88979
+ let targets = [];
88980
+ if (driver)
88981
+ try {
88982
+ targets = await driver.findComputeTargets({ slug: config6.project.slug, environment, role: "app" });
88983
+ instanceCount = targets.length;
88984
+ if (targets.length) {
88985
+ const result = await driver.runRemoteDeploy({
88986
+ targets: [targets[0]],
88987
+ commands: metricsScript(),
88988
+ comment: `ts-cloud dashboard:build ${config6.project.slug}`,
88989
+ tags: { Project: config6.project.slug, Environment: environment, Role: "app" }
88990
+ });
88991
+ const output = result.perInstance?.[0]?.output;
88992
+ if (output)
88993
+ parsed = parseBlock(output);
88994
+ }
88995
+ } catch {}
88996
+ const out = resolveConfigOnlyServerDashboardData(config6, environment);
88997
+ const num = (v, d = 0) => {
88998
+ const n = Number(v);
88999
+ return Number.isFinite(n) ? n : d;
89000
+ };
89001
+ out.server.os = parsed?.OS || out.server.os;
89002
+ out.server.uptime = parsed?.UPTIME || out.server.uptime;
89003
+ if (parsed) {
89004
+ out.server.probeStatus = "live";
89005
+ out.metricsUnavailable = false;
89006
+ out._metricsStatus = "live";
89007
+ out.systemMetrics = {
89008
+ load: num(parsed.LOAD),
89009
+ cpus: num(parsed.CPUS, 1),
89010
+ memUsedMb: num(parsed.MEMUSED),
89011
+ memTotalMb: num(parsed.MEMTOTAL, 1),
89012
+ diskUsedPct: num(parsed.DISKPCT),
89013
+ diskUsedGb: num(parsed.DISKUSEDG),
89014
+ diskTotalGb: num(parsed.DISKTOTG, 1)
89015
+ };
89016
+ if (parsed.services.length) {
89017
+ out.services = parsed.services.map((s) => ({ name: s.name, status: s.status === "active" ? "running" : s.status }));
89018
+ out.servicesDetail = parsed.services.map((s) => ({
89019
+ name: s.name,
89020
+ status: s.status === "active" ? "running" : s.status,
89021
+ since: s.since && s.since !== UNKNOWN ? s.since : out.server.uptime,
89022
+ memMb: Math.max(0, Math.round(Number(s.memBytes ?? 0) / 1024 / 1024)),
89023
+ auto: s.enabled ? !["disabled", "static", "masked"].includes(s.enabled) : true
89024
+ }));
89025
+ }
89026
+ }
89027
+ if (driver && targets.length) {
89028
+ try {
89029
+ const siteNames = Object.keys(config6.sites ?? {});
89030
+ const historyResult = await driver.runRemoteDeploy({
89031
+ targets: [targets[0]],
89032
+ commands: deployHistoryScript(siteNames),
89033
+ comment: `ts-cloud dashboard:deploy-history ${config6.project.slug}`,
89034
+ tags: { Project: config6.project.slug, Environment: environment, Role: "app" }
89035
+ });
89036
+ const historyOutput = historyResult.perInstance?.[0]?.output ?? "";
89037
+ const records = parseDeployHistory(historyOutput, config6.sites);
89038
+ out.serverDeployments = records.slice(0, 5);
89039
+ out.serverDeploymentsDetail = records.slice(0, 50);
89040
+ out.deploymentsEmptyReason = records.length ? undefined : "No deployment history was found on the server yet. Deploy again to populate this timeline.";
89041
+ } catch {
89042
+ out.deploymentsEmptyReason = "Deployment history could not be read from the server.";
89043
+ }
89044
+ } else if (driver && instanceCount === 0) {
89045
+ out.deploymentsEmptyReason = "No app server target was found for this environment.";
89046
+ }
89047
+ if (driver && targets.length) {
89048
+ try {
89049
+ const logsResult = await driver.runRemoteDeploy({
89050
+ targets: [targets[0]],
89051
+ commands: serverLogsScript(config6),
89052
+ comment: `ts-cloud dashboard:server-logs ${config6.project.slug}`,
89053
+ tags: { Project: config6.project.slug, Environment: environment, Role: "app" }
89054
+ });
89055
+ const logsOutput = logsResult.perInstance?.[0]?.output ?? "";
89056
+ const records = parseServerLogs(logsOutput);
89057
+ out.serverLogs = records;
89058
+ out.serverLogsEmptyReason = records.length ? undefined : "No recent journal entries were found for the managed server units.";
89059
+ } catch {
89060
+ out.serverLogsEmptyReason = "Server logs could not be read from the box.";
89061
+ }
89062
+ }
89063
+ if (driver && targets.length) {
89064
+ try {
89065
+ const securityResult = await driver.runRemoteDeploy({
89066
+ targets: [targets[0]],
89067
+ commands: securityScript(config6),
89068
+ comment: `ts-cloud dashboard:security ${config6.project.slug}`,
89069
+ tags: { Project: config6.project.slug, Environment: environment, Role: "app" }
89070
+ });
89071
+ const securityOutput = securityResult.perInstance?.[0]?.output ?? "";
89072
+ const liveSecurity = parseServerSecurity(securityOutput);
89073
+ const declaredSecurity = configuredSecurity(config6);
89074
+ if (!liveSecurity.ports.length)
89075
+ liveSecurity.ports = declaredSecurity.ports;
89076
+ if (!liveSecurity.tlsCertificates.length)
89077
+ liveSecurity.tlsCertificates = declaredSecurity.tlsCertificates;
89078
+ out.security = liveSecurity;
89079
+ out.securityEmptyReason = undefined;
89080
+ } catch {
89081
+ out.securityEmptyReason = "Server security checks could not be read from the box.";
89082
+ }
89083
+ }
89084
+ const sshKeys = config6.infrastructure?.compute?.sshKeys ?? config6.infrastructure?.ssh?.keys ?? config6.infrastructure?.sshKeys;
89085
+ if (Array.isArray(sshKeys) && sshKeys.length) {
89086
+ out.sshKeys = describeSshKeys(sshKeys);
89087
+ }
89088
+ out._serverReachable = instanceCount > 0 && !!parsed;
89089
+ out.diagnostics = diagnosticChecks(config6, out);
89090
+ out.activity = activityFeed(out);
89091
+ return out;
89092
+ }
89093
+
89094
+ // src/deploy/dashboard-database.ts
89095
+ var SOCKETS = {
89096
+ mysql: "/var/lib/pantry/mysql/mysqld.sock",
89097
+ mariadb: "/var/lib/pantry/mariadb/mariadbd.sock"
89098
+ };
89099
+ function isValidDbIdentifier(value) {
89100
+ return /^[A-Za-z_][A-Za-z0-9_]{0,62}$/.test(value);
89101
+ }
89102
+ function normalizeEngine(engine) {
89103
+ if (engine === "postgres" || engine === "pgsql")
89104
+ return "postgres";
89105
+ if (engine === "mariadb")
89106
+ return "mariadb";
89107
+ return "mysql";
89108
+ }
89109
+ function resolveDbEngine(config6) {
89110
+ const compute = config6.infrastructure?.compute;
89111
+ const declared = config6.infrastructure?.appDatabase?.engine;
89112
+ if (declared)
89113
+ return normalizeEngine(declared);
89114
+ const managed = compute?.managedServices ?? {};
89115
+ if (managed.postgres)
89116
+ return "postgres";
89117
+ if (managed.mariadb)
89118
+ return "mariadb";
89119
+ return "mysql";
89120
+ }
89121
+ var mysqlIdent = (v) => v.replace(/`/g, "``");
89122
+ var mysqlLit = (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
89123
+ var pgIdent = (v) => `"${v.replace(/"/g, '""')}"`;
89124
+ var pgLit = (v) => `'${v.replace(/'/g, "''")}'`;
89125
+ function mysqlExec(engine, sql) {
89126
+ const sock = engine === "mariadb" ? SOCKETS.mariadb : SOCKETS.mysql;
89127
+ return [`mysql --socket=${sock} -u root <<'TS_CLOUD_SQL_EOF'`, ...sql, "TS_CLOUD_SQL_EOF"];
89128
+ }
89129
+ function pgExec(sql) {
89130
+ return ["psql -h 127.0.0.1 -p 5432 -U postgres -tA <<'TS_CLOUD_PG_EOF'", ...sql, "TS_CLOUD_PG_EOF"];
89131
+ }
89132
+ function buildListScript(engine) {
89133
+ if (engine === "postgres") {
89134
+ return pgExec([
89135
+ "SELECT 'DB=' || datname FROM pg_database WHERE datistemplate = false;",
89136
+ "SELECT 'USER=' || usename FROM pg_user;"
89137
+ ]);
89138
+ }
89139
+ return mysqlExec(engine, [
89140
+ "SELECT CONCAT('DB=', schema_name) FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys');",
89141
+ "SELECT DISTINCT CONCAT('USER=', User) FROM mysql.user WHERE User NOT IN ('root', 'mysql.sys', 'mysql.session', 'mysql.infoschema', 'debian-sys-maint');"
89142
+ ]);
89143
+ }
89144
+ function buildCreateDatabaseScript(engine, name) {
89145
+ if (engine === "postgres") {
89146
+ return pgExec([
89147
+ `SELECT 'CREATE DATABASE ${pgIdent(name)}' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = ${pgLit(name)})\\gexec`
89148
+ ]);
89149
+ }
89150
+ return mysqlExec(engine, [
89151
+ `CREATE DATABASE IF NOT EXISTS \`${mysqlIdent(name)}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`
89152
+ ]);
89153
+ }
89154
+ function buildCreateUserScript(engine, input) {
89155
+ const { username, password, database, access: access4 } = input;
89156
+ if (engine === "postgres") {
89157
+ const lines2 = [
89158
+ "DO $$ BEGIN",
89159
+ ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${pgLit(username)}) THEN CREATE ROLE ${pgIdent(username)} LOGIN PASSWORD ${pgLit(password)};`,
89160
+ ` ELSE ALTER ROLE ${pgIdent(username)} LOGIN PASSWORD ${pgLit(password)}; END IF;`,
89161
+ "END $$;"
89162
+ ];
89163
+ if (database) {
89164
+ lines2.push(access4 === "readonly" ? `GRANT CONNECT ON DATABASE ${pgIdent(database)} TO ${pgIdent(username)};` : `GRANT ALL PRIVILEGES ON DATABASE ${pgIdent(database)} TO ${pgIdent(username)};`);
89165
+ }
89166
+ return pgExec(lines2);
89167
+ }
89168
+ const priv = access4 === "readonly" ? "SELECT" : "ALL PRIVILEGES";
89169
+ const lines = [
89170
+ `CREATE USER IF NOT EXISTS '${mysqlLit(username)}'@'%' IDENTIFIED BY '${mysqlLit(password)}';`,
89171
+ `CREATE USER IF NOT EXISTS '${mysqlLit(username)}'@'localhost' IDENTIFIED BY '${mysqlLit(password)}';`,
89172
+ `ALTER USER '${mysqlLit(username)}'@'%' IDENTIFIED BY '${mysqlLit(password)}';`,
89173
+ `ALTER USER '${mysqlLit(username)}'@'localhost' IDENTIFIED BY '${mysqlLit(password)}';`
89174
+ ];
89175
+ if (database) {
89176
+ lines.push(`GRANT ${priv} ON \`${mysqlIdent(database)}\`.* TO '${mysqlLit(username)}'@'%';`, `GRANT ${priv} ON \`${mysqlIdent(database)}\`.* TO '${mysqlLit(username)}'@'localhost';`);
89177
+ }
89178
+ lines.push("FLUSH PRIVILEGES;");
89179
+ return mysqlExec(engine, lines);
89180
+ }
89181
+ function parseDbList(output) {
89182
+ const databases = [];
89183
+ const users = [];
89184
+ for (const rawLine of output.split(`
89185
+ `)) {
89186
+ const line = rawLine.trim();
89187
+ if (line.startsWith("DB="))
89188
+ databases.push(line.slice(3));
89189
+ else if (line.startsWith("USER="))
89190
+ users.push(line.slice(5));
89191
+ }
89192
+ return {
89193
+ databases: [...new Set(databases)].filter(Boolean).sort(),
89194
+ users: [...new Set(users)].filter(Boolean).sort()
89195
+ };
89196
+ }
89197
+ async function runDb(config6, environment, commands, comment) {
89198
+ let driver;
89199
+ try {
89200
+ driver = createCloudDriver({ config: config6 });
89201
+ } catch (error2) {
89202
+ return { ok: false, error: `Could not initialize the cloud driver: ${error2?.message ?? error2}` };
89203
+ }
89204
+ const slug = config6.project.slug;
89205
+ const targets = await driver.findComputeTargets({ slug, environment, role: "app" });
89206
+ if (!targets.length)
89207
+ return { ok: false, error: "No app server target was found for this environment." };
89208
+ const result = await driver.runRemoteDeploy({
89209
+ targets: [targets[0]],
89210
+ commands: ["set -uo pipefail", ...commands],
89211
+ comment,
89212
+ tags: { Project: slug, Environment: environment, Role: "app" }
89213
+ });
89214
+ return { ok: result.success, stdout: result.perInstance?.[0]?.output ?? "", stderr: result.perInstance?.[0]?.error ?? result.error ?? "" };
89215
+ }
89216
+ async function listDatabases(config6, environment) {
89217
+ const engine = resolveDbEngine(config6);
89218
+ const r = await runDb(config6, environment, buildListScript(engine), "ts-cloud db:list");
89219
+ const parsed = r.ok && r.stdout ? parseDbList(r.stdout) : { databases: [], users: [] };
89220
+ return { ...r, engine, ...parsed };
89221
+ }
89222
+ async function createDatabase(config6, environment, name) {
89223
+ const engine = resolveDbEngine(config6);
89224
+ return runDb(config6, environment, buildCreateDatabaseScript(engine, name), `ts-cloud db:create ${name}`);
89225
+ }
89226
+ async function createDatabaseUser(config6, environment, input) {
89227
+ const engine = resolveDbEngine(config6);
89228
+ return runDb(config6, environment, buildCreateUserScript(engine, input), `ts-cloud db:user ${input.username}`);
89229
+ }
89230
+
89231
+ // src/drivers/shared/compute-ops.ts
89232
+ var noopLogger3 = {
89233
+ info: () => {},
89234
+ warn: () => {},
89235
+ error: () => {},
89236
+ step: () => {},
89237
+ success: () => {}
89238
+ };
89239
+ async function findTargets(ctx) {
89240
+ const targets = await ctx.driver.findComputeTargets({
89241
+ slug: ctx.slug,
89242
+ environment: ctx.environment,
89243
+ role: ctx.role || "app"
89244
+ });
89245
+ return targets;
89246
+ }
89247
+ async function rollbackComputeSite(ctx, options) {
89248
+ const logger4 = ctx.logger || noopLogger3;
89249
+ const targets = await findTargets(ctx);
89250
+ if (targets.length === 0)
89251
+ return { success: false, error: `No '${ctx.role || "app"}' servers found for ${ctx.slug}/${ctx.environment}.` };
89252
+ const appBase = `/var/www/${options.siteName}`;
89253
+ const paths = releasePaths(appBase, options.to || "unused");
89254
+ const commands = [
89255
+ "set -uo pipefail",
89256
+ ...buildRollbackScript(paths, options.to ? { to: options.to } : {}),
89257
+ `(cd ${PANTRY_PROJECT_DIR} && pantry restart php-fpm) 2>/dev/null || true`,
89258
+ `(cd ${paths.current} && eval "$(cd ${PANTRY_PROJECT_DIR} && pantry env 2>/dev/null)" && php artisan queue:restart) 2>/dev/null || true`
89259
+ ];
89260
+ logger4.step(`Rolling back ${options.siteName}${options.to ? ` to ${options.to}` : " to the previous release"} on ${targets.length} server(s)...`);
89261
+ const result = await ctx.driver.runRemoteDeploy({
89262
+ targets,
89263
+ commands,
89264
+ comment: `ts-cloud rollback ${ctx.slug}/${options.siteName}`,
89265
+ tags: { Project: ctx.slug, Environment: ctx.environment, Role: ctx.role || "app" }
89266
+ });
89267
+ if (!result.success)
89268
+ logger4.error(`Rollback failed: ${result.error || "unknown error"}`);
89269
+ else
89270
+ logger4.success(`Rolled back ${options.siteName}.`);
89271
+ return { success: result.success, error: result.error, perInstance: result.perInstance };
89272
+ }
89273
+ async function restoreDatabaseBackup(ctx, options) {
89274
+ const logger4 = ctx.logger || noopLogger3;
89275
+ if (!options.database?.name)
89276
+ return { success: false, error: "No appDatabase configured to restore." };
89277
+ const targets = await findTargets(ctx);
89278
+ if (targets.length === 0)
89279
+ return { success: false, error: `No '${ctx.role || "app"}' servers found for ${ctx.slug}/${ctx.environment}.` };
89280
+ logger4.step(`Restoring database '${options.database.name}'${options.from ? ` from ${options.from}` : " from the latest backup"} on ${targets.length} server(s)...`);
89281
+ const result = await ctx.driver.runRemoteDeploy({
89282
+ targets,
89283
+ commands: buildBackupRestoreScript(options.database, { from: options.from }),
89284
+ comment: `ts-cloud db:restore ${ctx.slug}/${options.database.name}`,
89285
+ tags: { Project: ctx.slug, Environment: ctx.environment, Role: ctx.role || "app" }
89286
+ });
89287
+ if (!result.success)
89288
+ logger4.error(`Restore failed: ${result.error || "unknown error"}`);
89289
+ else
89290
+ logger4.success(`Restored ${options.database.name}.`);
89291
+ return { success: result.success, error: result.error, perInstance: result.perInstance };
89292
+ }
89293
+
89294
+ // src/deploy/dashboard-operations.ts
89295
+ var SERVICE_VERBS = ["restart", "reload", "start", "stop", "enable", "disable"];
89296
+ var MAX_OUTPUT_BYTES = 64 * 1024;
89297
+ function clampOutput(output) {
89298
+ return output.length <= MAX_OUTPUT_BYTES ? output : `${output.slice(0, MAX_OUTPUT_BYTES)}
89299
+
89300
+ [output truncated]`;
89301
+ }
89302
+ function isSafeSystemdUnit(value) {
89303
+ return /^[A-Za-z0-9_.@:-]+$/.test(value);
89304
+ }
89305
+ function isSafeSiteName(value) {
89306
+ return /^[A-Za-z0-9_-]+$/.test(value);
89307
+ }
89308
+ function capitalize(value) {
89309
+ return value.charAt(0).toUpperCase() + value.slice(1);
89310
+ }
89311
+ function buildDashboardOperations(config6, data) {
89312
+ const ops = [];
89313
+ const services = data.servicesDetail ?? data.services ?? [];
89314
+ for (const service of services) {
89315
+ const name = String(service?.name ?? "");
89316
+ if (!name || !isSafeSystemdUnit(name))
89317
+ continue;
89318
+ for (const verb of SERVICE_VERBS) {
89319
+ ops.push({
89320
+ id: `${verb}:${name}`,
89321
+ label: `${capitalize(verb)} ${name}`,
89322
+ group: "service",
89323
+ target: name,
89324
+ mutates: true,
89325
+ confirm: name,
89326
+ danger: verb === "stop" || verb === "disable"
89327
+ });
89328
+ }
89329
+ }
89330
+ for (const [name, site] of Object.entries(config6.sites ?? {})) {
89331
+ if (!site || !isSafeSiteName(name))
89332
+ continue;
89333
+ const kind = resolveSiteKind(site);
89334
+ if (kind === "server-app" || kind === "server-php")
89335
+ ops.push({ id: `rollback:${name}`, label: `Rollback ${name}`, group: "deploy", target: name, mutates: true, confirm: name, danger: true });
89336
+ }
89337
+ const workerSites = new Set;
89338
+ for (const worker of data.workers ?? []) {
89339
+ const site = String(worker?.name ?? "").split(":")[0];
89340
+ if (site && isSafeSiteName(site))
89341
+ workerSites.add(site);
89342
+ }
89343
+ for (const site of workerSites)
89344
+ ops.push({ id: `worker:restart:${site}`, label: `Restart workers (${site})`, group: "worker", target: site, mutates: true, confirm: site });
89345
+ for (const [name, site] of Object.entries(config6.sites ?? {})) {
89346
+ if (!site || !isSafeSiteName(name))
89347
+ continue;
89348
+ if (site.scheduler || site.schedule)
89349
+ ops.push({ id: `scheduler:run:${name}`, label: `Run scheduler (${name})`, group: "scheduler", target: name, mutates: true, confirm: name });
89350
+ }
89351
+ const backups = config6.infrastructure?.compute?.backups;
89352
+ if (backups?.enabled) {
89353
+ ops.push({ id: "backup:run", label: "Run backup now", group: "backup", target: "backup", mutates: true, confirm: "backup" });
89354
+ if (config6.infrastructure?.appDatabase)
89355
+ ops.push({ id: "backup:restore", label: "Restore latest DB backup", group: "backup", target: "database", mutates: true, confirm: "restore", danger: true });
89356
+ }
89357
+ return ops;
89358
+ }
89359
+ function resolveDashboardOperation(id, config6, data) {
89360
+ return buildDashboardOperations(config6, data).find((op) => op.id === id);
89361
+ }
89362
+ function serviceCommand(verb, target) {
89363
+ const check = verb === "enable" || verb === "disable" ? `systemctl is-enabled ${target} 2>/dev/null || true` : `systemctl is-active ${target} 2>/dev/null || true`;
89364
+ return ["set -o pipefail", `systemctl ${verb} ${target}`, check];
89365
+ }
89366
+ function pantryEnvEval() {
89367
+ return `eval "$(cd ${PANTRY_PROJECT_DIR} && pantry env 2>/dev/null)"`;
89368
+ }
89369
+ function siteArtisanCommand(site, artisan) {
89370
+ const current = `/var/www/${site}/current`;
89371
+ return [
89372
+ "set -uo pipefail",
89373
+ `[ -d ${current} ] || { echo "site ${site} has no current release" >&2; exit 1; }`,
89374
+ `cd ${current} && ${pantryEnvEval()} && php artisan ${artisan}`
89375
+ ];
89376
+ }
89377
+ function backupRunCommand() {
89378
+ return [
89379
+ "set -uo pipefail",
89380
+ `[ -x ${BACKUP_RUNNER_PATH} ] || { echo "scheduled backups are not provisioned on this server" >&2; exit 1; }`,
89381
+ BACKUP_RUNNER_PATH
89382
+ ];
89383
+ }
89384
+ function fromComputeOps(id, command, r) {
89385
+ return {
89386
+ operation: id,
89387
+ command,
89388
+ ok: r.success,
89389
+ stdout: clampOutput(r.perInstance?.[0]?.output ?? ""),
89390
+ stderr: clampOutput(r.perInstance?.[0]?.error ?? r.error ?? "")
89391
+ };
89392
+ }
89393
+ async function runDashboardOperation(config6, environment, operation, options = {}) {
89394
+ const slug = config6.project.slug;
89395
+ let driver;
89396
+ try {
89397
+ driver = createCloudDriver({ config: config6 });
89398
+ } catch (error2) {
89399
+ return { operation: operation.id, ok: false, error: `Could not initialize the cloud driver: ${error2?.message ?? error2}` };
89400
+ }
89401
+ const ctx = { driver, slug, environment, role: "app" };
89402
+ if (operation.group === "deploy") {
89403
+ const to = options.to && /^[A-Za-z0-9._-]+$/.test(options.to) ? options.to : undefined;
89404
+ const r = await rollbackComputeSite(ctx, { siteName: operation.target, to });
89405
+ return fromComputeOps(operation.id, `rollback ${operation.target}${to ? ` → ${to}` : ""}`, r);
89406
+ }
89407
+ if (operation.id === "backup:restore") {
89408
+ const r = await restoreDatabaseBackup(ctx, { database: config6.infrastructure?.appDatabase });
89409
+ return fromComputeOps(operation.id, "db restore", r);
89410
+ }
89411
+ const targets = await driver.findComputeTargets({ slug, environment, role: "app" });
89412
+ if (!targets.length)
89413
+ return { operation: operation.id, ok: false, error: "No app server target was found for this environment." };
89414
+ let commands;
89415
+ let command;
89416
+ if (operation.group === "service") {
89417
+ const [verb, ...rest] = operation.id.split(":");
89418
+ commands = serviceCommand(verb, rest.join(":"));
89419
+ command = `systemctl ${verb} ${operation.target}`;
89420
+ } else if (operation.group === "worker") {
89421
+ commands = siteArtisanCommand(operation.target, "queue:restart");
89422
+ command = `queue:restart (${operation.target})`;
89423
+ } else if (operation.group === "scheduler") {
89424
+ commands = siteArtisanCommand(operation.target, "schedule:run");
89425
+ command = `schedule:run (${operation.target})`;
89426
+ } else if (operation.id === "backup:run") {
89427
+ commands = backupRunCommand();
89428
+ command = "ts-cloud backup";
89429
+ } else {
89430
+ return { operation: operation.id, ok: false, error: "Unknown operation." };
89431
+ }
89432
+ const result = await driver.runRemoteDeploy({
89433
+ targets: [targets[0]],
89434
+ commands,
89435
+ comment: `ts-cloud dashboard:${operation.id}`,
89436
+ tags: { Project: slug, Environment: environment, Role: "app" }
89437
+ });
89438
+ return {
89439
+ operation: operation.id,
89440
+ command,
89441
+ ok: result.success,
89442
+ stdout: clampOutput(result.perInstance?.[0]?.output ?? ""),
89443
+ stderr: clampOutput(result.perInstance?.[0]?.error ?? result.error ?? "")
89444
+ };
89445
+ }
89446
+
89447
+ // src/deploy/site-config-editor.ts
89448
+ function addSiteToCloudConfig(input) {
89449
+ const siteName = normalizeSiteName(input.name);
89450
+ const sitesObject = findSitesObject(input.configText);
89451
+ if (hasSite(input.configText, sitesObject.start, sitesObject.end, siteName)) {
89452
+ throw new Error(`Site '${siteName}' already exists in cloud.config.ts`);
89453
+ }
89454
+ const snippet = renderSiteSnippet({
89455
+ ...input,
89456
+ name: siteName
89457
+ });
89458
+ const before = input.configText.slice(0, sitesObject.end).trimEnd();
89459
+ const after = input.configText.slice(sitesObject.end);
89460
+ const separator = before.endsWith("{") ? `
89461
+ ` : before.endsWith(",") ? `
89462
+
89463
+ ` : `,
89464
+
89465
+ `;
89466
+ return `${before}${separator}${snippet}
89467
+ ${after}`;
89468
+ }
89469
+ function removeSiteFromCloudConfig(input) {
89470
+ const name = normalizeSiteName(input.name);
89471
+ const sites = findSitesObject(input.configText);
89472
+ const span = findSiteSpan(input.configText, sites.start, sites.end, name);
89473
+ if (!span)
89474
+ throw new Error(`Site '${name}' does not exist in cloud.config.ts`);
89475
+ const text = input.configText;
89476
+ let start = span.nameStart;
89477
+ while (start > 0 && text[start - 1] !== `
89478
+ ` && /\s/.test(text[start - 1]))
89479
+ start--;
89480
+ let end = span.end + 1;
89481
+ if (text[end] === ",")
89482
+ end++;
89483
+ while (end < text.length && text[end] !== `
89484
+ ` && /\s/.test(text[end]))
89485
+ end++;
89486
+ if (text[end] === `
89487
+ `)
89488
+ end++;
89489
+ return text.slice(0, start) + text.slice(end);
89490
+ }
89491
+ function setSitePropertyInCloudConfig(input) {
89492
+ const name = normalizeSiteName(input.siteName);
89493
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.key))
89494
+ throw new Error(`Invalid property key '${input.key}'`);
89495
+ const sites = findSitesObject(input.configText);
89496
+ const span = findSiteSpan(input.configText, sites.start, sites.end, name);
89497
+ if (!span)
89498
+ throw new Error(`Site '${name}' does not exist in cloud.config.ts`);
89499
+ const text = input.configText;
89500
+ const braceStart = text.indexOf("{", span.nameStart);
89501
+ const braceEnd = span.end;
89502
+ const body = text.slice(braceStart + 1, braceEnd);
89503
+ const propMatch = new RegExp(`(^|[\\s,{])(${escapeRegExp(input.key)})\\s*:`, "m").exec(body);
89504
+ if (propMatch) {
89505
+ const keyStart = braceStart + 1 + propMatch.index + propMatch[1].length;
89506
+ const colon = text.indexOf(":", keyStart);
89507
+ const valueEnd = scanValueEnd(text, colon + 1, braceEnd);
89508
+ return `${text.slice(0, keyStart)}${input.key}: ${input.valueText}${text.slice(valueEnd)}`;
89509
+ }
89510
+ const before = text.slice(0, braceEnd).trimEnd();
89511
+ const sep = before.endsWith("{") || before.endsWith(",") ? "" : ",";
89512
+ return `${before}${sep}
89513
+ ${input.key}: ${input.valueText},
89514
+ ${text.slice(braceEnd)}`;
89515
+ }
89516
+ function renderSslValue(ssl) {
89517
+ if (ssl === false)
89518
+ return "false";
89519
+ if (ssl === true)
89520
+ return "true";
89521
+ return `{ provider: '${escapeSingle(ssl.provider || "letsencrypt")}' }`;
89522
+ }
89523
+ function renderStringValue(value) {
89524
+ return `'${escapeSingle(value)}'`;
89525
+ }
89526
+ function renderEnvValue(env2) {
89527
+ const entries = Object.entries(env2).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
89528
+ if (entries.length === 0)
89529
+ return "{}";
89530
+ return `{
89531
+ ${entries.map(([key, value]) => ` ${key}: '${escapeSingle(String(value))}',`).join(`
89532
+ `)}
89533
+ }`;
89534
+ }
89535
+ function scanValueEnd(text, start, limit) {
89536
+ let i = start;
89537
+ while (i < limit && /\s/.test(text[i]))
89538
+ i++;
89539
+ let depth = 0;
89540
+ let quote;
89541
+ let escaped = false;
89542
+ for (;i < limit; i++) {
89543
+ const c = text[i];
89544
+ if (quote) {
89545
+ if (escaped) {
89546
+ escaped = false;
89547
+ continue;
89548
+ }
89549
+ if (c === "\\") {
89550
+ escaped = true;
89551
+ continue;
89552
+ }
89553
+ if (c === quote)
89554
+ quote = undefined;
89555
+ continue;
89556
+ }
89557
+ if (c === '"' || c === "'" || c === "`") {
89558
+ quote = c;
89559
+ continue;
89560
+ }
89561
+ if (c === "{" || c === "[" || c === "(") {
89562
+ depth++;
89563
+ continue;
89564
+ }
89565
+ if (c === "}" || c === "]" || c === ")") {
89566
+ if (depth === 0)
89567
+ return i;
89568
+ depth--;
89569
+ continue;
89570
+ }
89571
+ if (c === "," && depth === 0)
89572
+ return i;
89573
+ }
89574
+ return limit;
89575
+ }
89576
+ function findSiteSpan(configText, start, end, siteName) {
89577
+ const body = configText.slice(start + 1, end);
89578
+ const match = new RegExp(`(^|[\\s,{])(${escapeRegExp(siteName)})\\s*:\\s*\\{`, "m").exec(body);
89579
+ if (!match)
89580
+ return null;
89581
+ const nameStart = start + 1 + match.index + match[1].length;
89582
+ const braceStart = configText.indexOf("{", nameStart);
89583
+ return { nameStart, end: findMatchingBrace(configText, braceStart) };
89584
+ }
89585
+ function renderSiteSnippet(input) {
89586
+ const siteName = normalizeSiteName(input.name);
89587
+ const lines = [` ${siteName}: {`];
89588
+ pushString(lines, "deploy", input.deploy);
89589
+ pushString(lines, "root", input.root);
89590
+ pushString(lines, "path", input.path);
89591
+ pushString(lines, "domain", input.domain);
89592
+ pushString(lines, "type", input.type);
89593
+ pushString(lines, "build", input.build);
89594
+ pushString(lines, "start", input.start);
89595
+ if (input.port !== undefined)
89596
+ lines.push(` port: ${input.port},`);
89597
+ pushString(lines, "pathRewriteStyle", input.pathRewriteStyle);
89598
+ if (input.ssl !== undefined) {
89599
+ if (input.ssl === false)
89600
+ lines.push(" ssl: false,");
89601
+ else if (input.ssl === true)
89602
+ lines.push(" ssl: true,");
89603
+ else
89604
+ lines.push(` ssl: { provider: '${escapeSingle(input.ssl.provider || "letsencrypt")}' },`);
89605
+ }
89606
+ if (input.env && Object.keys(input.env).length > 0) {
89607
+ lines.push(" env: {");
89608
+ for (const [key, value] of Object.entries(input.env)) {
89609
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
89610
+ continue;
89611
+ lines.push(` ${key}: '${escapeSingle(String(value))}',`);
89612
+ }
89613
+ lines.push(" },");
89614
+ }
89615
+ lines.push(" },");
89616
+ return lines.join(`
89617
+ `);
89618
+ }
89619
+ function escapeSingle(value) {
89620
+ return value.replace(/\\/g, "\\\\").replaceAll(String.fromCharCode(39), "\\'");
89621
+ }
89622
+ function normalizeSiteName(name) {
89623
+ const normalized = name.trim();
89624
+ if (!/^[A-Za-z_$][\w$]*$/.test(normalized)) {
89625
+ throw new Error(`Invalid site name '${name}'. Use a valid JavaScript object key, e.g. docs or marketingSite.`);
89626
+ }
89627
+ return normalized;
89628
+ }
89629
+ function pushString(lines, key, value) {
89630
+ if (value === undefined || value === "")
89631
+ return;
89632
+ const escaped = value.replace(/\\/g, "\\\\").replaceAll(String.fromCharCode(39), "\\'");
89633
+ lines.push(` ${key}: '${escaped}',`);
89634
+ }
89635
+ function findSitesObject(configText) {
89636
+ const sitesMatch = /\bsites\s*:\s*{/g.exec(configText);
89637
+ if (!sitesMatch) {
89638
+ throw new Error("Could not find a top-level sites: { ... } block in cloud.config.ts");
89639
+ }
89640
+ const start = configText.indexOf("{", sitesMatch.index);
89641
+ const end = findMatchingBrace(configText, start);
89642
+ return { start, end };
89643
+ }
89644
+ function hasSite(configText, start, end, siteName) {
89645
+ const body = configText.slice(start + 1, end);
89646
+ return new RegExp(`(^|[\\s,{])${escapeRegExp(siteName)}\\s*:`, "m").test(body);
89647
+ }
89648
+ function escapeRegExp(value) {
89649
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
89650
+ }
89651
+ function findMatchingBrace(text, start) {
89652
+ let depth = 0;
89653
+ let quote;
89654
+ let escaped = false;
89655
+ let lineComment = false;
89656
+ let blockComment = false;
89657
+ for (let index = start;index < text.length; index++) {
89658
+ const char = text[index];
89659
+ const next = text[index + 1];
89660
+ if (lineComment) {
89661
+ if (char === `
89662
+ `)
89663
+ lineComment = false;
89664
+ continue;
89665
+ }
89666
+ if (blockComment) {
89667
+ if (char === "*" && next === "/") {
89668
+ blockComment = false;
89669
+ index++;
89670
+ }
89671
+ continue;
89672
+ }
89673
+ if (quote) {
89674
+ if (escaped) {
89675
+ escaped = false;
89676
+ continue;
89677
+ }
89678
+ if (char === "\\") {
89679
+ escaped = true;
89680
+ continue;
89681
+ }
89682
+ if (char === quote)
89683
+ quote = undefined;
89684
+ continue;
89685
+ }
89686
+ if (char === "/" && next === "/") {
89687
+ lineComment = true;
89688
+ index++;
89689
+ continue;
89690
+ }
89691
+ if (char === "/" && next === "*") {
89692
+ blockComment = true;
89693
+ index++;
89694
+ continue;
89695
+ }
89696
+ if (char === '"' || char === "'" || char === "`") {
89697
+ quote = char;
89698
+ continue;
89699
+ }
89700
+ if (char === "{")
89701
+ depth++;
89702
+ if (char === "}") {
89703
+ depth--;
89704
+ if (depth === 0)
89705
+ return index;
89706
+ }
89707
+ }
89708
+ throw new Error("Could not find the closing brace for sites: { ... } in cloud.config.ts");
89709
+ }
89710
+
89711
+ // src/deploy/local-dashboard-server.ts
89712
+ var DEFAULT_HOST = "127.0.0.1";
89713
+ var DEFAULT_PORT = 7676;
89714
+ var MAX_OUTPUT_BYTES2 = 64 * 1024;
89715
+ var here = dirname9(fileURLToPath6(import.meta.url));
89716
+ var contentTypes = {
89717
+ ".html": "text/html; charset=utf-8",
89718
+ ".css": "text/css; charset=utf-8",
89719
+ ".js": "text/javascript; charset=utf-8",
89720
+ ".json": "application/json; charset=utf-8",
89721
+ ".svg": "image/svg+xml",
89722
+ ".png": "image/png",
89723
+ ".jpg": "image/jpeg",
89724
+ ".jpeg": "image/jpeg",
89725
+ ".webp": "image/webp",
89726
+ ".ico": "image/x-icon"
89727
+ };
89728
+ function json(data, status = 200) {
89729
+ return new Response(JSON.stringify(data, null, 2), {
89730
+ status,
89731
+ headers: { "content-type": "application/json; charset=utf-8" }
89732
+ });
89733
+ }
89734
+ function text(data, status = 200) {
89735
+ return new Response(data, {
89736
+ status,
89737
+ headers: { "content-type": "text/plain; charset=utf-8" }
89738
+ });
89739
+ }
89740
+ function selectedEnvironment(config6, requested) {
89741
+ if (requested)
89742
+ return requested;
89743
+ const envs = Object.keys(config6.environments ?? {});
89744
+ return envs[0] ?? "production";
89745
+ }
89746
+ async function loadLocalEnv(cwd) {
89747
+ const candidates = [
89748
+ join23(here, "..", "..", "..", "..", ".env"),
89749
+ join23(cwd, ".env"),
89750
+ join23(cwd, ".env.local"),
89751
+ join23(cwd, ".env.production")
89752
+ ];
89753
+ for (const file of candidates) {
89754
+ if (!existsSync26(file))
89755
+ continue;
89756
+ const text2 = await readFile2(file, "utf8").catch(() => "");
89757
+ for (const line of text2.split(`
89758
+ `)) {
89759
+ const trimmed = line.trim();
89760
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("="))
89761
+ continue;
89762
+ const eq = trimmed.indexOf("=");
89763
+ const key = trimmed.slice(0, eq).trim();
89764
+ let value = trimmed.slice(eq + 1).trim();
89765
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(key) || process.env[key] != null)
89766
+ continue;
89767
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))
89768
+ value = value.slice(1, -1);
89769
+ process.env[key] = value;
89770
+ }
89771
+ }
89772
+ }
89773
+ function resolveCloudConfigPath(cwd) {
89774
+ const candidates = [
89775
+ join23(cwd, "config", "cloud.ts"),
89776
+ join23(cwd, "config", "cloud.js"),
89777
+ join23(cwd, "cloud.config.ts"),
89778
+ join23(cwd, "cloud.config.js")
89779
+ ];
89780
+ return candidates.find((file) => existsSync26(file)) ?? null;
89781
+ }
89782
+ function computeSshKeys(config6) {
89783
+ return config6.infrastructure?.compute?.sshKeys ?? [];
89784
+ }
89785
+ function replaceComputeSshKeys(config6, keys) {
89786
+ const infrastructure2 = config6.infrastructure ?? (config6.infrastructure = {});
89787
+ const compute = infrastructure2.compute ?? (infrastructure2.compute = {});
89788
+ compute.sshKeys = keys;
89789
+ }
89790
+ function replaceSiteConfig(config6, name, site) {
89791
+ const sites = config6.sites ?? (config6.sites = {});
89792
+ sites[name] = site;
89793
+ }
89794
+ async function readJsonBody(req) {
89795
+ return await req.json().catch(() => ({}));
89796
+ }
89797
+ function resolveDashboardMode(config6) {
89798
+ if (config6.mode === "hybrid")
89799
+ return "hybrid";
89800
+ return config6.infrastructure?.compute ? "server" : "serverless";
89801
+ }
89802
+ async function resolveLiveDashboardData(config6, environment) {
89803
+ const mode = resolveDashboardMode(config6);
89804
+ const meta = { mode, environment, environments: Object.keys(config6.environments ?? {}) };
89805
+ try {
89806
+ const data = mode === "serverless" ? await resolveDashboardData(config6, environment) : await resolveServerDashboardData(config6, environment);
89807
+ return { ...data ?? {}, ...meta };
89808
+ } catch {
89809
+ return { ...meta };
89810
+ }
89811
+ }
89812
+ function resolveUiSourceDir(cwd) {
89813
+ const candidates = [
89814
+ join23(cwd, "packages", "ui"),
89815
+ join23(here, "..", "..", "..", "ui"),
89816
+ join23(here, "..", "..", "ui"),
89817
+ join23(here, "..", "ui-src"),
89818
+ join23(here, "..", "..", "ui-src"),
89819
+ join23(here, "..", "..", "dist", "ui-src")
89820
+ ];
89821
+ for (const dir of candidates) {
89822
+ if (existsSync26(join23(dir, "pages")) && existsSync26(join23(dir, "package.json")))
89823
+ return dir;
89824
+ }
89825
+ return null;
89826
+ }
89827
+ async function buildLiveUi(cwd, data) {
89828
+ const uiDir = resolveUiSourceDir(cwd);
89829
+ if (!uiDir)
89830
+ return null;
89831
+ try {
89832
+ const outDir = mkdtempSync6(join23(tmpdir7(), "ts-cloud-dashboard-"));
89833
+ const localStx = join23(uiDir, "node_modules", ".bin", "stx");
89834
+ const cmd = existsSync26(localStx) ? [localStx, "build", "--pages", "pages", "--out", outDir, "--no-sitemap", "--no-cache"] : ["bunx", "--bun", "@stacksjs/stx", "build", "--pages", "pages", "--out", outDir, "--no-sitemap", "--no-cache"];
89835
+ const proc = Bun.spawn(cmd, {
89836
+ cwd: uiDir,
89837
+ stdout: "pipe",
89838
+ stderr: "pipe",
89839
+ env: {
89840
+ ...process.env,
89841
+ TSCLOUD_DASHBOARD_DATA: JSON.stringify(data)
89842
+ }
89843
+ });
89844
+ const [stdout, stderr, exitCode] = await Promise.all([
89845
+ new Response(proc.stdout).text(),
89846
+ new Response(proc.stderr).text(),
89847
+ proc.exited
89848
+ ]);
89849
+ if (exitCode !== 0) {
89850
+ if (process.env.TSCLOUD_DASHBOARD_VERBOSE)
89851
+ console.warn(`ts-cloud dashboard: live UI build failed; serving the pre-built UI.
89852
+ ${stdout}
89853
+ ${stderr}`);
89854
+ return null;
89855
+ }
89856
+ return outDir;
89857
+ } catch (err) {
89858
+ if (process.env.TSCLOUD_DASHBOARD_VERBOSE)
89859
+ console.warn(`ts-cloud dashboard: live UI build errored; serving the pre-built UI. ${err.message}`);
89860
+ return null;
89861
+ }
89862
+ }
89863
+ function sanitizeCloudConfig(config6) {
89864
+ return {
89865
+ project: {
89866
+ name: config6.project?.name,
89867
+ slug: config6.project?.slug,
89868
+ region: config6.project?.region
89869
+ },
89870
+ provider: config6.provider,
89871
+ environments: Object.keys(config6.environments ?? {}),
89872
+ compute: config6.infrastructure?.compute ? {
89873
+ provider: config6.infrastructure.compute.provider,
89874
+ runtime: config6.infrastructure.compute.runtime,
89875
+ webServer: config6.infrastructure.compute.webServer,
89876
+ proxy: config6.infrastructure.compute.proxy ? {
89877
+ engine: config6.infrastructure.compute.proxy.engine,
89878
+ onDemandTls: config6.infrastructure.compute.proxy.onDemandTls,
89879
+ cdn: !!config6.infrastructure.compute.proxy.cdn
89880
+ } : undefined,
89881
+ managedServices: config6.infrastructure.compute.managedServices,
89882
+ sshKeys: describeSshKeys(computeSshKeys(config6)).map((key) => ({
89883
+ name: key.name,
89884
+ type: key.type,
89885
+ fingerprint: key.fingerprint,
89886
+ added: key.added
89887
+ }))
89888
+ } : undefined,
89889
+ sites: Object.fromEntries(Object.entries(config6.sites ?? {}).map(([name, site]) => [
89890
+ name,
89891
+ {
89892
+ domain: site.domain,
89893
+ path: site.path,
89894
+ type: site.type,
89895
+ deploy: site.deploy,
89896
+ root: site.root,
89897
+ port: site.port,
89898
+ ssl: site.ssl
89899
+ }
89900
+ ]))
89901
+ };
89902
+ }
89903
+ function dashboardActions(environment) {
89904
+ return [
89905
+ {
89906
+ id: "status",
89907
+ label: "Refresh Cloud Status",
89908
+ description: "Runs the local ts-cloud status checks against configured providers.",
89909
+ command: ["status", "--env", environment],
89910
+ mutates: false
89911
+ },
89912
+ {
89913
+ id: "doctor",
89914
+ label: "Run Doctor",
89915
+ description: "Checks local tooling, credentials, and provider access.",
89916
+ command: ["doctor"],
89917
+ mutates: false
89918
+ },
89919
+ {
89920
+ id: "security-scan",
89921
+ label: "Security Scan",
89922
+ description: "Runs the pre-deploy scanner from this checkout.",
89923
+ command: ["deploy:security-scan", "--source", ".", "--fail-on", "critical"],
89924
+ mutates: false
89925
+ },
89926
+ {
89927
+ id: "deploy",
89928
+ label: "Deploy Environment",
89929
+ description: "Deploys the selected environment from the local checkout.",
89930
+ command: ["deploy", "--env", environment, "--yes"],
89931
+ mutates: true,
89932
+ confirm: "deploy"
89933
+ }
89934
+ ];
89935
+ }
89936
+ function resolveDashboardAction(id, environment) {
89937
+ return dashboardActions(environment).find((action) => action.id === id);
89938
+ }
89939
+ function clampOutput2(output) {
89940
+ if (output.length <= MAX_OUTPUT_BYTES2)
89941
+ return output;
89942
+ return `${output.slice(0, MAX_OUTPUT_BYTES2)}
89943
+
89944
+ [output truncated]`;
89945
+ }
89946
+ async function runAction(action, options) {
89947
+ const proc = Bun.spawn([process.execPath, options.cliEntry, ...action.command], {
89948
+ cwd: options.cwd,
89949
+ stdout: "pipe",
89950
+ stderr: "pipe",
89951
+ env: process.env
89952
+ });
89953
+ const [stdout, stderr, exitCode] = await Promise.all([
89954
+ new Response(proc.stdout).text(),
89955
+ new Response(proc.stderr).text(),
89956
+ proc.exited
89957
+ ]);
89958
+ return {
89959
+ action: action.id,
89960
+ command: ["cloud", ...action.command].join(" "),
89961
+ exitCode,
89962
+ ok: exitCode === 0,
89963
+ stdout: clampOutput2(stdout),
89964
+ stderr: clampOutput2(stderr)
89965
+ };
89966
+ }
89967
+ function staticPath(uiRoot, pathname) {
89968
+ const clean = decodeURIComponent(pathname).replace(/^\/+/, "");
89969
+ const wanted = clean === "" ? "index.html" : clean;
89970
+ const normalized = normalize(wanted);
89971
+ if (normalized.startsWith("..") || normalized.includes("/../"))
89972
+ return null;
89973
+ const base = join23(uiRoot, normalized);
89974
+ if (existsSync26(base) && !statSync6(base).isDirectory())
89975
+ return base;
89976
+ if (!extname3(base) && existsSync26(`${base}.html`))
89977
+ return `${base}.html`;
89978
+ if (!extname3(base) && existsSync26(join23(base, "index.html")))
89979
+ return join23(base, "index.html");
89980
+ return null;
89981
+ }
89982
+ async function serveStatic(uiRoot, pathname) {
89983
+ const file = staticPath(uiRoot, pathname);
89984
+ if (!file)
89985
+ return text("Not found", 404);
89986
+ if (statSync6(file).isDirectory())
89987
+ return text("Not found", 404);
89988
+ const body = await readFile2(file);
89989
+ const type = contentTypes[extname3(file)] ?? "application/octet-stream";
89990
+ return new Response(body, { headers: { "content-type": type } });
89991
+ }
89992
+ async function startLocalDashboardServer(options = {}) {
89993
+ const cwd = options.cwd ?? process.cwd();
89994
+ const host = options.host ?? DEFAULT_HOST;
89995
+ const port = options.port ?? DEFAULT_PORT;
89996
+ const cliEntry = options.cliEntry ?? process.argv[1];
89997
+ if (options.box)
89998
+ process.env.TS_CLOUD_DASHBOARD_BOX = "1";
89999
+ await loadLocalEnv(cwd);
90000
+ const config6 = await loadCloudConfig();
90001
+ const availableEnvironments = Object.keys(config6.environments ?? {});
90002
+ let environment = selectedEnvironment(config6, options.environment);
90003
+ let actions = dashboardActions(environment);
90004
+ const configPath = resolveCloudConfigPath(cwd);
90005
+ const initialData = await resolveLiveDashboardData(config6, environment);
90006
+ let latestData = initialData;
90007
+ const liveUiRoot = await buildLiveUi(cwd, initialData);
90008
+ const packagedUi = resolveUiSource(cwd);
90009
+ let activeUiRoot = liveUiRoot ?? packagedUi?.uiRoot;
90010
+ if (!activeUiRoot)
90011
+ throw new Error("ts-cloud dashboard UI not found. Run `bun run build` in ts-cloud or reinstall the package.");
90012
+ const server = Bun.serve({
90013
+ hostname: host,
90014
+ port,
90015
+ async fetch(req) {
90016
+ const url = new URL(req.url);
90017
+ try {
90018
+ if (url.pathname === "/api/health") {
90019
+ return json({
90020
+ ok: true,
90021
+ cwd,
90022
+ environment,
90023
+ environments: availableEnvironments,
90024
+ uiRoot: activeUiRoot,
90025
+ liveData: !!liveUiRoot,
90026
+ localPackage: import.meta.url.includes("/Code/Libraries/ts-cloud/")
90027
+ });
90028
+ }
90029
+ if (url.pathname === "/" && latestData?.mode === "serverless")
90030
+ return new Response(null, { status: 302, headers: { location: "/serverless" } });
90031
+ if (url.pathname === "/api/env" && req.method === "POST") {
90032
+ const body = await readJsonBody(req);
90033
+ const requested = String(body.env ?? "");
90034
+ if (!availableEnvironments.includes(requested))
90035
+ return json({ ok: false, error: `Unknown environment '${requested}'.`, environments: availableEnvironments }, 404);
90036
+ if (requested !== environment) {
90037
+ environment = requested;
90038
+ actions = dashboardActions(environment);
90039
+ latestData = await resolveLiveDashboardData(config6, environment);
90040
+ const rebuilt = await buildLiveUi(cwd, latestData);
90041
+ if (rebuilt)
90042
+ activeUiRoot = rebuilt;
90043
+ }
90044
+ return json({ ok: true, environment, environments: availableEnvironments });
90045
+ }
90046
+ if (url.pathname === "/api/config")
90047
+ return json({ ...sanitizeCloudConfig(config6), environment, environments: availableEnvironments });
90048
+ if (url.pathname === "/api/ssh-keys" && req.method === "GET") {
90049
+ return json({
90050
+ configPath,
90051
+ keys: describeSshKeys(computeSshKeys(config6))
90052
+ });
90053
+ }
90054
+ if (url.pathname === "/api/ssh-keys" && req.method === "POST") {
90055
+ if (!configPath)
90056
+ return json({ ok: false, error: "No cloud config file was found in this checkout." }, 404);
90057
+ const body = await readJsonBody(req);
90058
+ const before = await readFile2(configPath, "utf8");
90059
+ const currentKeys = computeSshKeys(config6);
90060
+ const after = addSshKeyToCloudConfig({
90061
+ configText: before,
90062
+ name: String(body.name ?? ""),
90063
+ publicKey: String(body.publicKey ?? ""),
90064
+ existingKeys: currentKeys
90065
+ });
90066
+ await writeFile6(configPath, after);
90067
+ replaceComputeSshKeys(config6, [...currentKeys, { name: String(body.name ?? "").trim(), publicKey: String(body.publicKey ?? "").trim().replace(/\s+/g, " ") }]);
90068
+ return json({ ok: true, configPath, keys: describeSshKeys(computeSshKeys(config6)) });
90069
+ }
90070
+ if (url.pathname === "/api/ssh-keys" && req.method === "DELETE") {
90071
+ if (!configPath)
90072
+ return json({ ok: false, error: "No cloud config file was found in this checkout." }, 404);
90073
+ const body = await readJsonBody(req);
90074
+ const name = String(body.name ?? url.searchParams.get("name") ?? "");
90075
+ const before = await readFile2(configPath, "utf8");
90076
+ const currentKeys = computeSshKeys(config6);
90077
+ const after = removeSshKeyFromCloudConfig({
90078
+ configText: before,
90079
+ name,
90080
+ existingKeys: currentKeys
90081
+ });
90082
+ await writeFile6(configPath, after);
90083
+ replaceComputeSshKeys(config6, currentKeys.filter((key) => key.name !== name.trim()));
90084
+ return json({ ok: true, configPath, keys: describeSshKeys(computeSshKeys(config6)) });
90085
+ }
90086
+ if (url.pathname === "/api/sites" && req.method === "POST") {
90087
+ if (!configPath)
90088
+ return json({ ok: false, error: "No cloud config file was found in this checkout." }, 404);
90089
+ const body = await readJsonBody(req);
90090
+ const siteName = String(body.name ?? "").trim();
90091
+ const root = String(body.root ?? "").trim();
90092
+ if (!siteName || !root)
90093
+ return json({ ok: false, error: "Site name and root are required." }, 422);
90094
+ const site = {
90095
+ deploy: body.deploy === "bucket" ? "bucket" : "server",
90096
+ root,
90097
+ path: String(body.path ?? "").trim() || undefined,
90098
+ domain: String(body.domain ?? "").trim() || undefined,
90099
+ build: String(body.build ?? "").trim() || undefined,
90100
+ start: String(body.start ?? "").trim() || undefined,
90101
+ port: body.port ? Number(body.port) : undefined,
90102
+ type: String(body.type ?? "").trim() || undefined
90103
+ };
90104
+ if (site.port !== undefined && (!Number.isInteger(site.port) || site.port < 1 || site.port > 65535))
90105
+ return json({ ok: false, error: "Port must be a number between 1 and 65535." }, 422);
90106
+ const before = await readFile2(configPath, "utf8");
90107
+ const after = addSiteToCloudConfig({
90108
+ configText: before,
90109
+ name: siteName,
90110
+ root: site.root,
90111
+ domain: site.domain,
90112
+ path: site.path,
90113
+ deploy: site.deploy,
90114
+ build: site.build,
90115
+ start: site.start,
90116
+ port: site.port,
90117
+ type: site.type
90118
+ });
90119
+ await writeFile6(configPath, after);
90120
+ replaceSiteConfig(config6, siteName, Object.fromEntries(Object.entries(site).filter(([, value]) => value !== undefined)));
90121
+ latestData = await resolveLiveDashboardData(config6, environment);
90122
+ return json({ ok: true, configPath, site: siteName, data: latestData });
90123
+ }
90124
+ if (url.pathname === "/api/sites" && req.method === "DELETE") {
90125
+ if (!configPath)
90126
+ return json({ ok: false, error: "No cloud config file was found in this checkout." }, 404);
90127
+ const body = await readJsonBody(req);
90128
+ const name = String(body.name ?? url.searchParams.get("name") ?? "").trim();
90129
+ if (!name || !config6.sites?.[name])
90130
+ return json({ ok: false, error: `Site '${name}' was not found.` }, 404);
90131
+ const before = await readFile2(configPath, "utf8");
90132
+ await writeFile6(configPath, removeSiteFromCloudConfig({ configText: before, name }));
90133
+ delete config6.sites[name];
90134
+ latestData = await resolveLiveDashboardData(config6, environment);
90135
+ return json({ ok: true, configPath, site: name, data: latestData });
90136
+ }
90137
+ if (url.pathname === "/api/sites" && req.method === "PATCH") {
90138
+ if (!configPath)
90139
+ return json({ ok: false, error: "No cloud config file was found in this checkout." }, 404);
90140
+ const body = await readJsonBody(req);
90141
+ const name = String(body.name ?? "").trim();
90142
+ const existing = config6.sites?.[name];
90143
+ if (!name || !existing)
90144
+ return json({ ok: false, error: `Site '${name}' was not found.` }, 404);
90145
+ if (body.port !== undefined && body.port !== null && body.port !== "" && (!Number.isInteger(Number(body.port)) || Number(body.port) < 1 || Number(body.port) > 65535))
90146
+ return json({ ok: false, error: "Port must be a number between 1 and 65535." }, 422);
90147
+ let text2 = await readFile2(configPath, "utf8");
90148
+ const set = (key, valueText) => {
90149
+ text2 = setSitePropertyInCloudConfig({ configText: text2, siteName: name, key, valueText });
90150
+ };
90151
+ if (body.ssl !== undefined) {
90152
+ set("ssl", renderSslValue(body.ssl));
90153
+ existing.ssl = body.ssl;
90154
+ }
90155
+ if (body.env !== undefined && body.env && typeof body.env === "object") {
90156
+ set("env", renderEnvValue(body.env));
90157
+ existing.env = body.env;
90158
+ }
90159
+ for (const key of ["domain", "path", "build", "start", "type", "root"]) {
90160
+ if (typeof body[key] === "string" && body[key].trim()) {
90161
+ set(key, renderStringValue(String(body[key]).trim()));
90162
+ existing[key] = String(body[key]).trim();
90163
+ }
90164
+ }
90165
+ if (body.port !== undefined && body.port !== null && body.port !== "") {
90166
+ set("port", String(Number(body.port)));
90167
+ existing.port = Number(body.port);
90168
+ }
90169
+ await writeFile6(configPath, text2);
90170
+ latestData = await resolveLiveDashboardData(config6, environment);
90171
+ return json({ ok: true, configPath, site: name, data: latestData });
90172
+ }
90173
+ if (url.pathname === "/api/sites/deploy" && req.method === "POST") {
90174
+ const body = await readJsonBody(req);
90175
+ const name = String(body.name ?? "").trim();
90176
+ if (!name || !config6.sites?.[name])
90177
+ return json({ ok: false, error: `Site '${name}' was not found.` }, 404);
90178
+ if (body.confirm !== name)
90179
+ return json({ ok: false, error: `Type "${name}" to deploy this site.` }, 409);
90180
+ const action = {
90181
+ id: `deploy:${name}`,
90182
+ label: `Deploy ${name}`,
90183
+ description: `Deploy the ${name} site for ${environment}.`,
90184
+ command: ["deploy", "--env", environment, "--site", name, "--yes"],
90185
+ mutates: true
90186
+ };
90187
+ return json(await runAction(action, { cwd, cliEntry }));
90188
+ }
90189
+ if (url.pathname === "/api/actions")
90190
+ return json(actions);
90191
+ if (url.pathname === "/api/server/operations")
90192
+ return json(buildDashboardOperations(config6, latestData));
90193
+ if (url.pathname === "/api/databases" && req.method === "GET")
90194
+ return json(await listDatabases(config6, environment));
90195
+ if (url.pathname === "/api/databases" && req.method === "POST") {
90196
+ const body = await readJsonBody(req);
90197
+ const name = String(body.name ?? "").trim();
90198
+ if (!isValidDbIdentifier(name))
90199
+ return json({ ok: false, error: "Database name must be a valid identifier (letters, numbers, underscore; not starting with a digit)." }, 422);
90200
+ return json({ ...await createDatabase(config6, environment, name), name });
90201
+ }
90202
+ if (url.pathname === "/api/databases/users" && req.method === "POST") {
90203
+ const body = await readJsonBody(req);
90204
+ const username = String(body.username ?? "").trim();
90205
+ const password = String(body.password ?? "");
90206
+ const database = String(body.database ?? "").trim() || undefined;
90207
+ const access4 = body.access === "readonly" ? "readonly" : "all";
90208
+ if (!isValidDbIdentifier(username))
90209
+ return json({ ok: false, error: "Username must be a valid identifier." }, 422);
90210
+ if (!password)
90211
+ return json({ ok: false, error: "Password is required." }, 422);
90212
+ if (database && !isValidDbIdentifier(database))
90213
+ return json({ ok: false, error: "Database must be a valid identifier." }, 422);
90214
+ return json({ ...await createDatabaseUser(config6, environment, { username, password, database, access: access4 }), username });
90215
+ }
90216
+ if (url.pathname === "/api/dashboard-data") {
90217
+ latestData = await resolveLiveDashboardData(config6, environment);
90218
+ return json(latestData);
90219
+ }
90220
+ if (url.pathname === "/api/actions/run" && req.method === "POST") {
90221
+ const body = await req.json().catch(() => ({}));
90222
+ const action = body.action ? resolveDashboardAction(body.action, environment) : undefined;
90223
+ if (!action)
90224
+ return json({ ok: false, error: "Unknown dashboard action." }, 404);
90225
+ if (action.mutates && body.confirm !== action.confirm)
90226
+ return json({ ok: false, error: `Type "${action.confirm}" to run this action.` }, 409);
90227
+ return json(await runAction(action, { cwd, cliEntry }));
90228
+ }
90229
+ if (url.pathname === "/api/server/operations/run" && req.method === "POST") {
90230
+ const body = await req.json().catch(() => ({}));
90231
+ const operation = body.operation ? resolveDashboardOperation(body.operation, config6, latestData) : undefined;
90232
+ if (!operation)
90233
+ return json({ ok: false, error: "Unknown or unavailable server operation." }, 404);
90234
+ if (operation.mutates && body.confirm !== operation.confirm)
90235
+ return json({ ok: false, error: `Type "${operation.confirm}" to run this operation.` }, 409);
90236
+ return json(await runDashboardOperation(config6, environment, operation, { to: body.to }));
90237
+ }
90238
+ const requestedEnv = url.searchParams.get("env");
90239
+ if (requestedEnv && availableEnvironments.includes(requestedEnv) && requestedEnv !== environment) {
90240
+ environment = requestedEnv;
90241
+ actions = dashboardActions(environment);
90242
+ latestData = await resolveLiveDashboardData(config6, environment);
90243
+ const rebuilt = await buildLiveUi(cwd, latestData);
90244
+ if (rebuilt)
90245
+ activeUiRoot = rebuilt;
90246
+ }
90247
+ return serveStatic(activeUiRoot, url.pathname);
90248
+ } catch (error2) {
90249
+ if (options.verbose)
90250
+ console.error(error2);
90251
+ return json({ ok: false, error: error2?.message ?? String(error2) }, 500);
90252
+ }
90253
+ }
90254
+ });
90255
+ return { server, url: `http://${host}:${server.port}/` };
90256
+ }
87090
90257
  // src/index.ts
87091
90258
  init_dns();
87092
90259
  init_dist2();
@@ -87120,6 +90287,7 @@ export {
87120
90287
  suggestCommand,
87121
90288
  storageAdvancedManager,
87122
90289
  staticSiteManager,
90290
+ startLocalDashboardServer,
87123
90291
  stackDependencyManager,
87124
90292
  signRequestAsync,
87125
90293
  signRequest,
@@ -87136,6 +90304,7 @@ export {
87136
90304
  secretsManager,
87137
90305
  searchCommands,
87138
90306
  sanitizeName,
90307
+ sanitizeCloudConfig,
87139
90308
  runRemoteCommand,
87140
90309
  runPhpBuildHooks,
87141
90310
  runBuildHooks,
@@ -87164,6 +90333,7 @@ export {
87164
90333
  resolveExecStart,
87165
90334
  resolveDeployBucketName,
87166
90335
  resolveDashboardDomain,
90336
+ resolveDashboardAction,
87167
90337
  resolveCredentials,
87168
90338
  resolveCloudProvider,
87169
90339
  resolveApp,
@@ -87330,6 +90500,7 @@ export {
87330
90500
  defaultLocalConfig,
87331
90501
  defaultConfig4 as defaultConfig,
87332
90502
  databaseUserManager,
90503
+ dashboardActions,
87333
90504
  crossRegionReferenceManager,
87334
90505
  createZip,
87335
90506
  createWordPressPreset,