@stacksjs/buddy 0.70.74 → 0.70.76

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.
@@ -106,6 +106,10 @@ async function install(path, options) {
106
106
  log.error(result.error);
107
107
  process.exit(ExitCode.FatalError);
108
108
  }
109
+ log.info("Removing template-encrypted env files...");
110
+ const { rm } = await import("node:fs/promises");
111
+ for (const stale of [".env.development", ".env.staging", ".env.production", ".env.keys"])
112
+ await rm(`${path}/${stale}`, { force: !0 });
109
113
  log.info("Generating application key...");
110
114
  const keyResult = await runAction(Action.KeyGenerate, { ...options, cwd: path });
111
115
  if (keyResult.isErr) {
@@ -71,8 +71,10 @@ export declare function provisionMailTenant(ip: string, logger: typeof log): Pro
71
71
  * per record): MX → the mail host, SPF authorizing the box IP, the domain's DKIM
72
72
  * public key at `mail._domainkey`, and a DMARC policy. Best-effort — logged, not
73
73
  * thrown. No-op without Porkbun credentials (the records are printed to add by
74
- * hand). MX targets the shared mail host (`mail.stacksjs.com`), so no per-domain
75
- * mail A record or extra TLS SAN is needed.
74
+ * hand). MX targets the tenant's own `mail.<domain>` when that name already
75
+ * resolves to the box (own-brand mail host; requires the mail cert to cover it
76
+ * as a SAN), and falls back to the shared mail host (`mail.stacksjs.com`)
77
+ * otherwise, where no per-domain mail A record or extra TLS SAN is needed.
76
78
  */
77
79
  export declare function reconcileMailDns(res: MailTenantResult, ip: string, logger: typeof log): Promise<void>;
78
80
  export declare function deploy(buddy: CLI): void;
@@ -4,8 +4,8 @@ import { join } from "node:path";
4
4
  import process from "node:process";
5
5
  import { runAction } from "@stacksjs/actions";
6
6
  import { italic, onUnknownSubcommand, outro, prompts, runCommand } from "@stacksjs/cli";
7
- import { app, email as emailConfig, cloud as cloudConfig } from "@stacksjs/config";
8
- import { addDomain, hasUserDomainBeenAddedToCloud } from "@stacksjs/dns";
7
+ import { app, dns as dnsConfig, email as emailConfig, cloud as cloudConfig } from "@stacksjs/config";
8
+ import { addDomain, hasUserDomainBeenAddedToCloud, syncDnsConfig } from "@stacksjs/dns";
9
9
  import { encryptEnv, env } from "@stacksjs/env";
10
10
  import { Action } from "@stacksjs/enums";
11
11
  import { path as p } from "@stacksjs/path";
@@ -691,7 +691,7 @@ async function runHetznerDeploy(args) {
691
691
  log.info(`Attaching to '${attachTo}' box '${box.serverName}' (${ip}) \u2014 skipping provisioning`);
692
692
  const compute = (tsCloudConfig.infrastructure ??= {}).compute ??= {};
693
693
  compute.webServer = "rpx";
694
- compute.proxy = { ...compute.proxy ?? {}, engine: "rpx" };
694
+ compute.proxy = { onDemandTls: !0, ...compute.proxy ?? {}, engine: "rpx" };
695
695
  const stackName = tsCloudConfig.project?.stackName || `${tsCloudConfig.project?.slug || "app"}-${environment}`, stateDir = join(process.cwd(), ".ts-cloud", "state");
696
696
  mkdirSync(stateDir, { recursive: !0 });
697
697
  writeFileSync(join(stateDir, `${stackName}.json`), `${JSON.stringify({
@@ -760,7 +760,7 @@ async function runHetznerDeploy(args) {
760
760
  }
761
761
  const root = site.root || ".", tarballPath = join(tmpdir(), `${slug}-${siteName}-${sha}.tar.gz`);
762
762
  log.info(`Packaging ${root} \u2192 ${tarballPath}...`);
763
- execSync(`tar czf "${tarballPath}" ${tarExcludes.join(" ")} -C "${root}" .`, { stdio: verbose ? "inherit" : "pipe" });
763
+ execSync(`tar czf "${tarballPath}" ${tarExcludes.join(" ")} -C "${root}" .`, { stdio: verbose ? "inherit" : "pipe", env: { ...process.env, COPYFILE_DISABLE: "1" } });
764
764
  const sizeMb = Math.max(1, Math.round(statSync(tarballPath).size / 1048576));
765
765
  log.info(`Release tarball: ~${sizeMb} MB`);
766
766
  tarballs.set(siteName, tarballPath);
@@ -796,6 +796,8 @@ async function runHetznerDeploy(args) {
796
796
  });
797
797
  if (ok)
798
798
  await reconcileHetznerDns(onlySite ? { [onlySite]: sites[onlySite] } : sites, ip, log);
799
+ if (ok)
800
+ await reconcileConfigDns(onlySite ? { [onlySite]: sites[onlySite] } : sites, log);
799
801
  if (ok) {
800
802
  const mailRes = await provisionMailTenant(ip, log);
801
803
  if (mailRes)
@@ -967,7 +969,14 @@ if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo
967
969
  }
968
970
  }
969
971
  export async function reconcileMailDns(res, ip, logger) {
970
- const { domain, mailHost, dkimPubB64 } = res, spf = `v=spf1 ip4:${ip} ~all`, dmarc = `v=DMARC1; p=quarantine; rua=mailto:chris@${domain}`, dkim = dkimPubB64 ? `v=DKIM1; k=rsa; p=${dkimPubB64}` : void 0, apiKey = process.env.PORKBUN_API_KEY, secretKey = process.env.PORKBUN_SECRET_KEY;
972
+ const { domain, dkimPubB64 } = res;
973
+ let { mailHost } = res;
974
+ try {
975
+ const dns = await import("node:dns"), own = `mail.${domain}`;
976
+ if (own !== mailHost && (await dns.promises.resolve4(own)).includes(ip))
977
+ mailHost = own;
978
+ } catch {}
979
+ const spf = `v=spf1 ip4:${ip} ~all`, cfg = emailConfig || {}, firstBox = resolveMailboxes(cfg.mailboxes, domain)[0]?.address, dmarc = `v=DMARC1; p=quarantine; rua=mailto:${(typeof cfg.from?.address === "string" && cfg.from.address.includes("@") ? cfg.from.address : void 0) || firstBox || `chris@${domain}`}`, dkim = dkimPubB64 ? `v=DKIM1; k=rsa; p=${dkimPubB64}` : void 0, apiKey = process.env.PORKBUN_API_KEY, secretKey = process.env.PORKBUN_SECRET_KEY;
971
980
  if (!apiKey || !secretKey) {
972
981
  logger.warn(`Mail DNS: no Porkbun credentials \u2014 add these records for ${domain} by hand:`);
973
982
  logger.info(` MX @ 10 ${mailHost}`);
@@ -1001,6 +1010,24 @@ export async function reconcileMailDns(res, ip, logger) {
1001
1010
  logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`);
1002
1011
  }
1003
1012
  }
1013
+ async function reconcileConfigDns(sites, logger) {
1014
+ if (["a", "aaaa", "cname", "mx", "txt"].reduce((total, key) => total + (Array.isArray(dnsConfig?.[key]) ? dnsConfig[key].length : 0), 0) === 0)
1015
+ return;
1016
+ const domains = new Set;
1017
+ for (const site of Object.values(sites))
1018
+ if (site?.domain && typeof site.domain === "string")
1019
+ domains.add(site.domain.replace(/^www\./, ""));
1020
+ for (const domain of domains)
1021
+ try {
1022
+ const result = await syncDnsConfig(domain, dnsConfig);
1023
+ if (!result.provider)
1024
+ continue;
1025
+ if (result.created || result.failed)
1026
+ logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed ? `, ${result.failed} failed` : ""}`);
1027
+ } catch (err) {
1028
+ logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error ? err.message : String(err)}`);
1029
+ }
1030
+ }
1004
1031
  async function reconcileHetznerDns(sites, ip, logger) {
1005
1032
  const domains = new Set;
1006
1033
  for (const site of Object.values(sites))
@@ -1,6 +1,7 @@
1
1
  import process from "node:process";
2
2
  import { log, onUnknownSubcommand } from "@stacksjs/cli";
3
3
  import { config } from "@stacksjs/config";
4
+ import { renderDnsConfig, resolveLiveRecords, syncDnsConfig } from "@stacksjs/dns";
4
5
  import { ExitCode } from "@stacksjs/types";
5
6
  export function dns(buddy) {
6
7
  const descriptions = {
@@ -58,5 +59,44 @@ export function dns(buddy) {
58
59
  }
59
60
  process.exit(ExitCode.Success);
60
61
  });
62
+ const bareDomain = (input) => (input || config.app.url || "").replace(/^[a-z]+:\/\//i, "").replace(/[/:].*$/, "");
63
+ buddy.command("dns:pull [domain]", "Print a domain's live DNS records as a config/dns.ts block").option("--verbose", descriptions.verbose, { default: !1 }).action(async (domain, options) => {
64
+ const target = bareDomain(domain);
65
+ log.debug(`Running \`buddy dns:pull ${target}\` ...`, options);
66
+ const records = await resolveLiveRecords(target);
67
+ if (!records.length) {
68
+ log.error(`No DNS records resolved for ${target}.`);
69
+ process.exit(ExitCode.FatalError);
70
+ }
71
+ console.log(renderDnsConfig(target, records));
72
+ process.exit(ExitCode.Success);
73
+ });
74
+ buddy.command("dns:diff [domain]", "Show which config/dns.ts records are missing from the live zone").option("--verbose", descriptions.verbose, { default: !1 }).action(async (domain, options) => {
75
+ const target = bareDomain(domain);
76
+ log.debug(`Running \`buddy dns:diff ${target}\` ...`, options);
77
+ const { plan, provider } = await syncDnsConfig(target, config.dns, { dryRun: !0 });
78
+ for (const item of plan.items) {
79
+ const detail = item.record.type === "TXT" || item.record.type === "MX" ? ` ${item.record.content}` : ` \u2192 ${item.record.content}`;
80
+ console.log(` ${item.action === "create" ? "+ create" : " keep "} ${item.record.type.padEnd(5)} ${item.record.name}${detail}`);
81
+ }
82
+ console.log(`
83
+ ${plan.create.length} to create, ${plan.keep.length} already present (${provider ? `registrar: ${provider}` : "public DNS"})`);
84
+ process.exit(ExitCode.Success);
85
+ });
86
+ buddy.command("dns:sync [domain]", "Additively sync config/dns.ts to the registrar (creates missing records; never deletes or overwrites)").option("--dry-run", "Show the plan without writing any records", { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (domain, options) => {
87
+ const target = bareDomain(domain);
88
+ log.debug(`Running \`buddy dns:sync ${target}\` ...`, options);
89
+ const result = await syncDnsConfig(target, config.dns, { dryRun: options.dryRun });
90
+ if (!result.provider && !options.dryRun) {
91
+ log.warn(`No DNS provider credentials found (e.g. PORKBUN_API_KEY / PORKBUN_SECRET_KEY) \u2014 nothing was synced. ${result.plan.create.length} record(s) would be created.`);
92
+ process.exit(ExitCode.Success);
93
+ }
94
+ for (const record of result.plan.create)
95
+ console.log(` ${result.applied ? "created" : "would create"} ${record.type.padEnd(5)} ${record.name} \u2192 ${record.content}`);
96
+ const verb = result.applied ? "created" : "to create", count = result.applied ? result.created : result.plan.create.length;
97
+ console.log(`
98
+ dns:sync ${target}: ${count} ${verb}, ${result.kept} kept${result.failed ? `, ${result.failed} failed` : ""}${result.provider ? ` (${result.provider})` : ""}`);
99
+ process.exit(result.failed > 0 ? ExitCode.FatalError : ExitCode.Success);
100
+ });
61
101
  onUnknownSubcommand(buddy, "dns");
62
102
  }
@@ -15,6 +15,9 @@ const commandRegistry = {
15
15
  deploy: { path: "./commands/deploy.js", exportName: "deploy" },
16
16
  dev: { path: "./commands/dev.js", exportName: "dev" },
17
17
  dns: { path: "./commands/dns.js", exportName: "dns" },
18
+ "dns:pull": { path: "./commands/dns.js", exportName: "dns" },
19
+ "dns:diff": { path: "./commands/dns.js", exportName: "dns" },
20
+ "dns:sync": { path: "./commands/dns.js", exportName: "dns" },
18
21
  doctor: { path: "./commands/doctor.js", exportName: "doctor" },
19
22
  domains: { path: "./commands/domains.js", exportName: "domains" },
20
23
  email: { path: "./commands/email.js", exportName: "email" },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
- "version": "0.70.74",
4
+ "version": "0.70.76",
5
5
  "description": "Meet Buddy. The Stacks runtime.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [
@@ -135,7 +135,7 @@
135
135
  "@stacksjs/ui": "^0.70.23",
136
136
  "@stacksjs/utils": "^0.70.23",
137
137
  "@stacksjs/validation": "^0.70.23",
138
- "@stacksjs/ts-cloud": "^0.7.14"
138
+ "@stacksjs/ts-cloud": "^0.7.15"
139
139
  },
140
140
  "devDependencies": {
141
141
  "better-dx": "^0.2.16"