@stacksjs/buddy 0.70.90 → 0.70.91

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.
@@ -692,7 +692,7 @@ async function runHetznerDeploy(args) {
692
692
  const compute = (tsCloudConfig.infrastructure ??= {}).compute ??= {};
693
693
  compute.webServer = "rpx";
694
694
  compute.proxy = { onDemandTls: !0, ...compute.proxy ?? {}, engine: "rpx" };
695
- const stackName = tsCloudConfig.project?.stackName || `${tsCloudConfig.project?.slug || "app"}-${environment}`, stateDir = join(process.cwd(), ".ts-cloud", "state");
695
+ const stackName = tsCloudConfig.project?.stackName || `${tsCloudConfig.project?.slug || "app"}-${environment}`, stateDir = join(process.cwd(), "storage", "cloud", "state");
696
696
  mkdirSync(stateDir, { recursive: !0 });
697
697
  writeFileSync(join(stateDir, `${stackName}.json`), `${JSON.stringify({
698
698
  stackName,
@@ -950,7 +950,60 @@ if [ -n "$FWD_B64" ] && [ -x /usr/local/bin/bun ]; then
950
950
  rm -f /tmp/.mailtenant-fwd.json /tmp/.mailtenant-readme.txt
951
951
  fi
952
952
  FWD_STATE=nochange; grep -q FWDCHANGED /tmp/.mailtenant-res 2>/dev/null && FWD_STATE=updated; rm -f /tmp/.mailtenant-res
953
- # 5) Restart only when the startup-read env actually changed (domain or DKIM key).
953
+ # 5) Keep the shared daemon recoverable if it crashes or stops accepting mail.
954
+ mkdir -p /etc/systemd/system/mail.service.d
955
+ cat > /etc/systemd/system/mail.service.d/reliability.conf <<'EOF'
956
+ [Unit]
957
+ StartLimitIntervalSec=60
958
+ StartLimitBurst=10
959
+
960
+ [Service]
961
+ Restart=always
962
+ RestartSec=2
963
+ TimeoutStartSec=30
964
+ TimeoutStopSec=30
965
+ LimitCORE=infinity
966
+ EOF
967
+ cat > /usr/local/sbin/mail-health-check <<'EOF'
968
+ #!/bin/sh
969
+ set -eu
970
+ exec 9>/run/mail-health-check.lock
971
+ flock -n 9 || exit 0
972
+ systemctl is-active --quiet mail || { systemctl restart mail; exit 0; }
973
+ for port in 25 143 587 993; do
974
+ ss -H -ltn "sport = :$port" | grep -q . || {
975
+ logger -t mail-health "required TCP port $port is not listening; restarting mail"
976
+ systemctl restart mail
977
+ exit 0
978
+ }
979
+ done
980
+ EOF
981
+ chmod 755 /usr/local/sbin/mail-health-check
982
+ cat > /etc/systemd/system/mail-health.service <<'EOF'
983
+ [Unit]
984
+ Description=Check the Stacks mail daemon listeners
985
+ After=mail.service
986
+
987
+ [Service]
988
+ Type=oneshot
989
+ ExecStart=/usr/local/sbin/mail-health-check
990
+ EOF
991
+ cat > /etc/systemd/system/mail-health.timer <<'EOF'
992
+ [Unit]
993
+ Description=Check the Stacks mail daemon every minute
994
+
995
+ [Timer]
996
+ OnBootSec=2min
997
+ OnUnitActiveSec=1min
998
+ AccuracySec=10s
999
+ Persistent=true
1000
+
1001
+ [Install]
1002
+ WantedBy=timers.target
1003
+ EOF
1004
+ systemctl daemon-reload
1005
+ systemctl enable --now mail-health.timer >/dev/null 2>&1
1006
+ # 6) Restart only when the startup-read env actually changed (domain or DKIM key).
954
1007
  if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo "MAILTENANT:env-changed+restarted,forwards=$FWD_STATE"; else echo "MAILTENANT:current,forwards=$FWD_STATE"; fi`;
955
1008
  try {
956
1009
  const out = execSync(`ssh ${sshArgs.map((a) => `'${a}'`).join(" ")} bash -s`, {
@@ -6,6 +6,7 @@ import { bold, cyan, dim, green, intro, log, onUnknownSubcommand, outro, prompts
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { promises as dns } from "node:dns";
9
10
  import { Action } from "@stacksjs/enums";
10
11
  import { libsPath, projectPath } from "@stacksjs/path";
11
12
  import { ExitCode } from "@stacksjs/types";
@@ -813,7 +814,14 @@ async function unregisterRpxProxies(ids) {
813
814
  } catch {}
814
815
  }
815
816
  async function prepareRpxTlsForDev(input) {
816
- const { domain, includeDashboard, options } = input, verbose = options.verbose ?? !1, hosts = [
817
+ const { domain, includeDashboard, options } = input, verbose = options.verbose ?? !1;
818
+ if (process.env.STACKS_DEV_ALLOW_PUBLIC_DOMAIN !== "1") {
819
+ if ((await dns.resolve4(domain).catch(() => [])).some((address) => !address.startsWith("127."))) {
820
+ log.warn(`Skipping local DNS override for public domain ${domain}; use a .localhost/.test domain for development`);
821
+ return;
822
+ }
823
+ }
824
+ const hosts = [
817
825
  domain,
818
826
  ...includeDashboard ? [`dashboard.${domain}`] : []
819
827
  ], hostsNeedingFile = hosts.filter((host) => {
@@ -1,2 +1,4 @@
1
1
  import { CLI } from '@stacksjs/clapp';
2
+ export declare function resolveDirectMailHost(env?: NodeJS.ProcessEnv): string | undefined;
3
+ export declare function sanitizeLineCount(value?: string): string;
2
4
  export declare function mailCommands(buddy: CLI): void;
@@ -5,6 +5,41 @@ import { CLI } from "@stacksjs/clapp";
5
5
  import { createHash, createHmac, randomBytes } from "crypto";
6
6
  import { readFileSync, existsSync } from "node:fs";
7
7
  import { execSync } from "node:child_process";
8
+ export function resolveDirectMailHost(env = process.env) {
9
+ let configured = env.MAIL_SERVER_HOST || (env.HCLOUD_TOKEN || env.HETZNER_API_TOKEN ? env.MAIL_HOST : void 0);
10
+ if (configured && ["127.0.0.1", "localhost", "mailpit"].includes(configured))
11
+ configured = env.MAIL_DOMAIN ? `mail.${env.MAIL_DOMAIN}` : void 0;
12
+ if (!configured || configured === "127.0.0.1" || configured === "localhost")
13
+ return;
14
+ return configured.replace(/^https?:\/\//, "").split("/")[0]?.split(":")[0];
15
+ }
16
+ export function sanitizeLineCount(value) {
17
+ const count = Number.parseInt(value || "50", 10);
18
+ return String(Number.isFinite(count) ? Math.min(5000, Math.max(1, count)) : 50);
19
+ }
20
+ function shellQuote(value) {
21
+ return `'${value.replace(/'/g, "'\\''")}'`;
22
+ }
23
+ async function resolveOperationalMailHost() {
24
+ if (process.env.MAIL_SERVER_HOST)
25
+ return resolveDirectMailHost();
26
+ const token = process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN;
27
+ if (!token)
28
+ return;
29
+ const appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
30
+ try {
31
+ const ip = (await (await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`, {
32
+ headers: { Authorization: `Bearer ${token}` }
33
+ })).json()).servers?.[0]?.public_net?.ipv4?.ip;
34
+ if (ip)
35
+ return ip;
36
+ } catch {}
37
+ const configured = resolveDirectMailHost();
38
+ if (configured)
39
+ return configured;
40
+ const { email } = await import("@stacksjs/config");
41
+ return email?.domain ? `mail.${email.domain}` : void 0;
42
+ }
8
43
  export function mailCommands(buddy) {
9
44
  buddy.command("mail:provision", "Provision this app's mail from config/email.ts onto the shared mail server (domain + DKIM + mailboxes + MX/SPF/DKIM/DMARC DNS). Reusable + idempotent; the same reconcile buddy deploy runs.").option("--ip <ip>", "Mail server IP (defaults to the A record of config.email.domain)").action(async (options) => {
10
45
  const { email: emailConfig } = await import("@stacksjs/config"), domain = emailConfig?.domain;
@@ -198,6 +233,18 @@ Shutting down proxy...`);
198
233
  });
199
234
  buddy.command("mail:logs", "Show mail server logs from production").option("-n, --lines <count>", "Number of log lines to show", { default: "50" }).option("-f, --follow", "Follow log output (poll every 5s)").option("--filter <pattern>", "Filter logs by pattern (e.g. AUTH, LOGIN, error)").action(async (options) => {
200
235
  loadEnvFiles();
236
+ const directHost = await resolveOperationalMailHost();
237
+ if (directHost) {
238
+ const lines = sanitizeLineCount(options.lines), filter = options.filter ? options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g, "") : "", command = `journalctl -u mail --no-pager -n ${lines}${filter ? ` | grep -iE ${shellQuote(filter)}` : ""}`;
239
+ try {
240
+ const output = execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`, { encoding: "utf8" });
241
+ console.log(output);
242
+ process.exit(ExitCode.Success);
243
+ } catch (error) {
244
+ log.error(`Failed to fetch logs from ${directHost}: ${getErrorMessage(error)}`);
245
+ process.exit(ExitCode.FatalError);
246
+ }
247
+ }
201
248
  const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
202
249
  try {
203
250
  const { EC2Client, SSMClient } = await import("@stacksjs/ts-cloud"), ec2 = new EC2Client(region), ssm = new SSMClient(region);
@@ -214,7 +261,7 @@ Shutting down proxy...`);
214
261
  process.exit(ExitCode.FatalError);
215
262
  }
216
263
  log.info(`Found instance: ${instanceId}`);
217
- const lines = options.lines || "50", sanitizedFilter = options.filter ? options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g, "") : "", filterCmd = sanitizedFilter ? ` | grep -iE '${sanitizedFilter}'` : "", fetchLogs = async () => {
264
+ const lines = sanitizeLineCount(options.lines), sanitizedFilter = options.filter ? options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g, "") : "", filterCmd = sanitizedFilter ? ` | grep -iE '${sanitizedFilter}'` : "", fetchLogs = async () => {
218
265
  const cmd = `journalctl -u smtp-server -u mail-server --no-pager -n ${lines} --since "10 minutes ago" 2>&1${filterCmd}`, result = await ssm.runShellCommand(instanceId, [cmd], {
219
266
  timeoutSeconds: 30,
220
267
  maxWaitMs: 30000,
@@ -275,6 +322,18 @@ Shutting down proxy...`);
275
322
  });
276
323
  buddy.command("mail:status", "Show mail server status").action(async () => {
277
324
  loadEnvFiles();
325
+ const directHost = await resolveOperationalMailHost();
326
+ if (directHost) {
327
+ const command = 'systemctl is-active mail; systemctl show mail -p NRestarts -p ActiveEnterTimestamp --value; ss -H -ltn | grep -E "(:(25|143|465|587|993))\\b"; systemctl is-enabled mail-health.timer; systemctl is-active mail-health.timer';
328
+ try {
329
+ const output = execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`, { encoding: "utf8" });
330
+ console.log(output);
331
+ process.exit(ExitCode.Success);
332
+ } catch (error) {
333
+ log.error(`Mail server ${directHost} is unhealthy: ${getErrorMessage(error)}`);
334
+ process.exit(ExitCode.FatalError);
335
+ }
336
+ }
278
337
  const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
279
338
  try {
280
339
  const { EC2Client, SSMClient } = await import("@stacksjs/ts-cloud"), ec2 = new EC2Client(region), ssm = new SSMClient(region), instanceId = (await ec2.describeInstances({
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.90",
5
+ "version": "0.70.91",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [