@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.
- package/dist/commands/deploy.js +55 -2
- package/dist/commands/dev.js +9 -1
- package/dist/commands/mail.d.ts +2 -0
- package/dist/commands/mail.js +60 -1
- package/package.json +1 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -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(), "
|
|
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)
|
|
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`, {
|
package/dist/commands/dev.js
CHANGED
|
@@ -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
|
|
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) => {
|
package/dist/commands/mail.d.ts
CHANGED
package/dist/commands/mail.js
CHANGED
|
@@ -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
|
|
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({
|