@stacksjs/buddy 0.70.257 → 0.70.259

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 (113) hide show
  1. package/dist/cli.d.ts +11 -1
  2. package/dist/cli.js +5 -164
  3. package/dist/commands/about.js +1 -35
  4. package/dist/commands/add.js +1 -25
  5. package/dist/commands/ai-context.js +4 -27
  6. package/dist/commands/auth.js +1 -68
  7. package/dist/commands/build.js +1 -221
  8. package/dist/commands/cd.js +8 -39
  9. package/dist/commands/changelog.js +1 -32
  10. package/dist/commands/clean.js +1 -44
  11. package/dist/commands/cloud.js +2 -519
  12. package/dist/commands/commit.js +1 -16
  13. package/dist/commands/completion.js +6 -42
  14. package/dist/commands/config-migrate.js +6 -94
  15. package/dist/commands/configure.js +2 -45
  16. package/dist/commands/create.js +1 -162
  17. package/dist/commands/deploy.d.ts +10 -4
  18. package/dist/commands/deploy.js +13 -1664
  19. package/dist/commands/desktop-apple.js +12 -399
  20. package/dist/commands/dev.js +6 -1083
  21. package/dist/commands/dns.js +3 -103
  22. package/dist/commands/docs/buddy-commands.js +10 -132
  23. package/dist/commands/docs/buddy-commands.test.js +1 -38
  24. package/dist/commands/docs/framework-repo.js +1 -14
  25. package/dist/commands/docs/framework-repo.test.js +1 -68
  26. package/dist/commands/docs/generated-artifacts.js +3 -70
  27. package/dist/commands/docs/generated-artifacts.test.js +1 -19
  28. package/dist/commands/docs/links.js +2 -85
  29. package/dist/commands/docs/links.test.js +4 -66
  30. package/dist/commands/docs.js +1 -24
  31. package/dist/commands/doctor.js +2 -478
  32. package/dist/commands/domains.js +2 -152
  33. package/dist/commands/email.js +31 -503
  34. package/dist/commands/env.js +1 -312
  35. package/dist/commands/extension.js +1 -239
  36. package/dist/commands/features.js +9 -345
  37. package/dist/commands/fresh.js +1 -46
  38. package/dist/commands/generate.js +2 -152
  39. package/dist/commands/http.js +1 -33
  40. package/dist/commands/index.js +1 -57
  41. package/dist/commands/install.js +1 -22
  42. package/dist/commands/key.js +1 -23
  43. package/dist/commands/link.js +2 -147
  44. package/dist/commands/lint.js +2 -46
  45. package/dist/commands/list.js +1 -140
  46. package/dist/commands/mail.js +15 -1477
  47. package/dist/commands/maintenance.js +1 -141
  48. package/dist/commands/make.js +1 -392
  49. package/dist/commands/migrate-project.js +1 -49
  50. package/dist/commands/migrate.js +27 -625
  51. package/dist/commands/outdated.js +1 -19
  52. package/dist/commands/package.js +1 -17
  53. package/dist/commands/phone.js +17 -181
  54. package/dist/commands/ports.js +2 -118
  55. package/dist/commands/prepublish.js +1 -17
  56. package/dist/commands/projects.js +1 -29
  57. package/dist/commands/protocol/craft-evidence.js +4 -98
  58. package/dist/commands/protocol/craft-evidence.test.js +1 -20
  59. package/dist/commands/protocol/desktop-lifecycle-report.js +3 -57
  60. package/dist/commands/protocol/desktop-lifecycle-report.test.js +1 -32
  61. package/dist/commands/protocol/desktop-support.js +2 -32
  62. package/dist/commands/protocol/desktop-workflow.test.js +3 -35
  63. package/dist/commands/protocol/driver-registry.js +2 -71
  64. package/dist/commands/protocol/pantry-evidence.js +4 -144
  65. package/dist/commands/protocol/pantry-evidence.test.js +1 -29
  66. package/dist/commands/protocol/release-manifest.js +3 -102
  67. package/dist/commands/protocol/release-manifest.test.js +2 -34
  68. package/dist/commands/protocol/run-conformance.js +4 -346
  69. package/dist/commands/protocol/run-conformance.test.js +1 -52
  70. package/dist/commands/protocol/run-driver-contracts.js +4 -83
  71. package/dist/commands/protocol/run-driver-contracts.test.js +1 -18
  72. package/dist/commands/protocol/source-manifest.js +3 -113
  73. package/dist/commands/protocol/sync-suite.js +3 -136
  74. package/dist/commands/protocol/sync-suite.test.js +1 -44
  75. package/dist/commands/protocol.js +1 -60
  76. package/dist/commands/publish.js +8 -500
  77. package/dist/commands/queue.js +1 -248
  78. package/dist/commands/release.js +1 -42
  79. package/dist/commands/route.js +1 -21
  80. package/dist/commands/run-tool.js +1 -9
  81. package/dist/commands/saas.js +1 -25
  82. package/dist/commands/schedule.js +1 -61
  83. package/dist/commands/search.js +1 -84
  84. package/dist/commands/seed.js +1 -47
  85. package/dist/commands/serve.js +1 -55
  86. package/dist/commands/setup-ai.js +2 -121
  87. package/dist/commands/setup.js +2 -305
  88. package/dist/commands/share.js +1 -214
  89. package/dist/commands/sms.js +32 -319
  90. package/dist/commands/stacks.js +1 -72
  91. package/dist/commands/telemetry.js +1 -74
  92. package/dist/commands/test.js +1 -130
  93. package/dist/commands/tinker.js +1 -37
  94. package/dist/commands/types.js +1 -18
  95. package/dist/commands/upgrade.js +1 -98
  96. package/dist/commands/user.js +3 -84
  97. package/dist/commands/version.js +1 -14
  98. package/dist/config.js +1 -231
  99. package/dist/custom-cli.js +1 -23
  100. package/dist/database-preflight.js +1 -88
  101. package/dist/global-options.js +1 -4
  102. package/dist/index.js +1 -1
  103. package/dist/lazy-commands.js +1 -241
  104. package/dist/migrators/index.js +2 -62
  105. package/dist/migrators/laravel/index.js +1 -148
  106. package/dist/migrators/laravel/migrations.js +4 -231
  107. package/dist/migrators/laravel/models.js +8 -111
  108. package/dist/migrators/rails/index.js +1 -11
  109. package/dist/production-server.js +1 -257
  110. package/dist/project-setup.js +1 -36
  111. package/dist/serve-entry.js +1 -5
  112. package/dist/version-info.js +1 -5
  113. package/package.json +54 -47
@@ -1,675 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { isAbsolute, join, relative, resolve } from "node:path";
4
- import process from "node:process";
5
- import { pathToFileURL } from "node:url";
6
- import { runAction } from "@stacksjs/actions";
7
- import { italic, onUnknownSubcommand, outro, prompts, runCommand } from "@stacksjs/cli";
8
- import { app, dns as dnsConfig, email as emailConfig, cloud as cloudConfig } from "@stacksjs/config";
9
- import { addDomain, hasUserDomainBeenAddedToCloud, syncDnsConfig } from "@stacksjs/dns";
10
- import { loadProjectDnsConfig } from "../config";
11
- import { encryptEnv, env } from "@stacksjs/env";
12
- import { Action } from "@stacksjs/enums";
13
- import { path as p } from "@stacksjs/path";
14
- import { ExitCode } from "@stacksjs/types";
15
- import { getErrorCode, getErrorMessage } from "@stacksjs/utils";
16
- import { ensureAppKey, ensureEnvIsSet } from "./setup";
17
- const log = {
18
- info: (...args) => console.log("\u2139", ...args),
19
- success: (...args) => console.log("\u2713", ...args),
20
- warn: (...args) => console.log("\u26A0", ...args),
21
- error: (...args) => console.error("\u2717", ...args),
22
- debug: (...args) => {
23
- if (process.argv.includes("--verbose") || process.argv.includes("-v"))
24
- console.log("\uD83D\uDD0D", ...args);
25
- }
26
- }, MAIL_PACKAGE_DOMAIN = "github.com/mail-os/mail", MAIL_PACKAGE_SPEC = `${MAIL_PACKAGE_DOMAIN}@0.1.0`, MAIL_TARGET_PLATFORM = "linux-x86_64", MAIL_BINARY_NAMES = ["mail", "mail-x86_64-linux", "mail-x86_64-linux-gnu"];
27
- function collectMatchingFiles(root, names, maxDepth = 8) {
28
- const nameSet = new Set(names), matches = [];
29
- if (!existsSync(root))
30
- return matches;
31
- function walk(dir, depth) {
32
- if (depth > maxDepth)
33
- return;
34
- for (const entry of readdirSync(dir)) {
35
- if (entry === ".git" || entry === "node_modules")
36
- continue;
37
- const fullPath = join(dir, entry), stat = statSync(fullPath);
38
- if (stat.isDirectory())
39
- walk(fullPath, depth + 1);
40
- else if (stat.isFile() && nameSet.has(entry))
41
- matches.push(fullPath);
42
- }
43
- }
44
- walk(root, 0);
45
- return matches;
46
- }
47
- function isElfBinary(filePath) {
48
- const header = readFileSync(filePath).slice(0, 4);
49
- return header[0] === 127 && header[1] === 69 && header[2] === 76 && header[3] === 70;
50
- }
51
- function resolvePantryInstallCommand() {
52
- const localPantryCli = join(homedir(), "Code", "Tools", "pantry", "packages", "ts-pantry", "bin", "cli.ts");
53
- if (existsSync(localPantryCli))
54
- return { command: "bun", args: [localPantryCli] };
55
- const projectPantry = p.projectPath("pantry/.bin/pantry");
56
- if (existsSync(projectPantry))
57
- return { command: projectPantry, args: [] };
58
- const globalPantry = join(homedir(), ".local", "share", "pantry", "global", "bin", "pantry");
59
- if (existsSync(globalPantry))
60
- return { command: globalPantry, args: [] };
61
- return { command: "pantry", args: [] };
62
- }
63
- async function installMailBinaryWithPantry() {
64
- const { execFileSync } = await import("node:child_process"), pantry = resolvePantryInstallCommand();
65
- execFileSync(pantry.command, [
66
- ...pantry.args,
67
- "install",
68
- MAIL_PACKAGE_SPEC,
69
- "--install-dir",
70
- p.projectPath("pantry"),
71
- "--platform",
72
- MAIL_TARGET_PLATFORM,
73
- "--quiet"
74
- ], {
75
- cwd: p.projectPath(),
76
- stdio: process.argv.includes("--verbose") || process.argv.includes("-v") ? "inherit" : "pipe",
77
- env: process.env
78
- });
79
- }
80
- async function findPantryMailBinary() {
81
- const directCandidates = [
82
- ...MAIL_BINARY_NAMES.map((name) => p.projectPath(`pantry/.bin/${name}`)),
83
- ...MAIL_BINARY_NAMES.map((name) => join(homedir(), ".local", "share", "pantry", "global", "bin", name))
84
- ];
85
- for (const candidate of directCandidates)
86
- if (existsSync(candidate) && isElfBinary(candidate))
87
- return candidate;
88
- for (const root of [p.projectPath("pantry"), join(homedir(), ".local", "share", "pantry")])
89
- for (const candidate of collectMatchingFiles(root, MAIL_BINARY_NAMES))
90
- if (isElfBinary(candidate))
91
- return candidate;
92
- return null;
93
- }
94
- async function ensureDeployPrerequisites(verbose = !1) {
95
- const cwd = p.projectPath();
96
- await ensureEnvIsSet({ cwd, verbose });
97
- await ensureAppKey(cwd);
98
- }
99
- function loadAwsCredentialsFromFile() {
100
- const credentialsPath = join(homedir(), ".aws", "credentials"), configPath = join(homedir(), ".aws", "config");
101
- if (!existsSync(credentialsPath))
102
- return {};
103
- try {
104
- const lines = readFileSync(credentialsPath, "utf-8").split(`
105
- `), profiles = ["default", "stacks"];
106
- let currentProfile = "", credentials = {};
107
- const profileCredentials = {};
108
- for (const line of lines) {
109
- const trimmed = line.trim(), profileMatch = trimmed.match(/^\[(.+)\]$/);
110
- if (profileMatch?.[1]) {
111
- currentProfile = profileMatch[1];
112
- profileCredentials[currentProfile] = {};
113
- continue;
114
- }
115
- const keyValue = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
116
- if (keyValue && currentProfile) {
117
- const [, key, value] = keyValue, target = profileCredentials[currentProfile];
118
- if (!target || value === void 0)
119
- continue;
120
- if (key === "aws_access_key_id")
121
- target.accessKeyId = value;
122
- else if (key === "aws_secret_access_key")
123
- target.secretAccessKey = value;
124
- }
125
- }
126
- for (const profile of profiles)
127
- if (profileCredentials[profile]?.accessKeyId && profileCredentials[profile]?.secretAccessKey) {
128
- credentials = profileCredentials[profile];
129
- log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);
130
- break;
131
- }
132
- if (!credentials.accessKeyId) {
133
- for (const [profile, creds] of Object.entries(profileCredentials))
134
- if (creds.accessKeyId && creds.secretAccessKey) {
135
- credentials = creds;
136
- log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);
137
- break;
138
- }
139
- }
140
- let region;
141
- if (existsSync(configPath)) {
142
- const regionMatch = readFileSync(configPath, "utf-8").match(/region\s*=\s*(.+)/);
143
- if (regionMatch?.[1])
144
- region = regionMatch[1].trim();
145
- }
146
- return { ...credentials, region };
147
- } catch (error) {
148
- log.debug("Failed to read AWS credentials file:", error);
149
- return {};
150
- }
151
- }
152
- async function setupEmailDnsRecords(emailDomain, region, logger, options) {
153
- logger.info("Setting up email DNS records...");
154
- try {
155
- const { SESClient } = await import("@stacksjs/ts-cloud"), { Route53Client } = await import("@stacksjs/ts-cloud"), ses = new SESClient(region), route53 = new Route53Client(region);
156
- logger.info(`Getting DKIM tokens for ${emailDomain}...`);
157
- const tokens = (await ses.getEmailIdentity(emailDomain)).DkimAttributes?.Tokens || [];
158
- if (tokens.length === 0) {
159
- logger.warn("No DKIM tokens found - domain may not be set up in SES yet");
160
- return;
161
- }
162
- logger.info(`Found ${tokens.length} DKIM tokens`);
163
- const zone = (await route53.listHostedZones()).HostedZones?.find((z) => z.Name === `${emailDomain}.`);
164
- if (!zone) {
165
- logger.warn(`Hosted zone not found for ${emailDomain} - DNS records must be added manually`);
166
- logger.info("DKIM records needed:");
167
- for (const token of tokens)
168
- logger.info(` CNAME: ${token}._domainkey.${emailDomain} -> ${token}.dkim.amazonses.com`);
169
- logger.info(` MX: ${emailDomain} -> 10 inbound-smtp.${region}.amazonaws.com`);
170
- return;
171
- }
172
- const hostedZoneId = zone.Id?.replace("/hostedzone/", "");
173
- logger.info(`Found hosted zone: ${hostedZoneId}`);
174
- for (const token of tokens) {
175
- const recordName = `${token}._domainkey.${emailDomain}`, recordValue = `${token}.dkim.amazonses.com`;
176
- try {
177
- await route53.changeResourceRecordSets({
178
- HostedZoneId: hostedZoneId,
179
- ChangeBatch: {
180
- Changes: [{
181
- Action: "UPSERT",
182
- ResourceRecordSet: {
183
- Name: recordName,
184
- Type: "CNAME",
185
- TTL: 300,
186
- ResourceRecords: [{ Value: recordValue }]
187
- }
188
- }]
189
- }
190
- });
191
- logger.success(`Added DKIM record: ${token}._domainkey`);
192
- } catch (e) {
193
- logger.warn(`Failed to add DKIM record: ${getErrorMessage(e)}`);
194
- }
195
- }
196
- const mailSubdomain = options?.mailSubdomain || "mail", mxTarget = options?.mode === "server" ? `10 ${mailSubdomain}.${emailDomain}` : `10 inbound-smtp.${region}.amazonaws.com`;
197
- try {
198
- await route53.changeResourceRecordSets({
199
- HostedZoneId: hostedZoneId,
200
- ChangeBatch: {
201
- Changes: [{
202
- Action: "UPSERT",
203
- ResourceRecordSet: {
204
- Name: emailDomain,
205
- Type: "MX",
206
- TTL: 300,
207
- ResourceRecords: [{ Value: mxTarget }]
208
- }
209
- }]
210
- }
211
- });
212
- logger.success(`Added MX record: ${mxTarget}`);
213
- } catch (e) {
214
- logger.warn(`Failed to add MX record: ${getErrorMessage(e)}`);
215
- }
216
- try {
217
- await route53.changeResourceRecordSets({
218
- HostedZoneId: hostedZoneId,
219
- ChangeBatch: {
220
- Changes: [{
221
- Action: "UPSERT",
222
- ResourceRecordSet: {
223
- Name: emailDomain,
224
- Type: "TXT",
225
- TTL: 300,
226
- ResourceRecords: [{ Value: '"v=spf1 include:amazonses.com ~all"' }]
227
- }
228
- }]
229
- }
230
- });
231
- logger.success("Added SPF record");
232
- } catch (e) {
233
- logger.warn(`Failed to add SPF record: ${getErrorMessage(e)}`);
234
- }
235
- try {
236
- await route53.changeResourceRecordSets({
237
- HostedZoneId: hostedZoneId,
238
- ChangeBatch: {
239
- Changes: [{
240
- Action: "UPSERT",
241
- ResourceRecordSet: {
242
- Name: `_dmarc.${emailDomain}`,
243
- Type: "TXT",
244
- TTL: 300,
245
- ResourceRecords: [{ Value: `"v=DMARC1;p=quarantine;pct=25;rua=mailto:dmarcreports@${emailDomain}"` }]
246
- }
247
- }]
248
- }
249
- });
250
- logger.success("Added DMARC record");
251
- } catch (e) {
252
- logger.warn(`Failed to add DMARC record: ${getErrorMessage(e)}`);
253
- }
254
- try {
255
- const ruleSetName = `${process.env.APP_NAME?.toLowerCase().replace(/[^a-z0-9]/g, "-") || "stacks"}-email-rules`;
256
- await ses.setActiveReceiptRuleSet(ruleSetName);
257
- logger.success(`Activated email receipt rule set: ${ruleSetName}`);
258
- } catch (e) {
259
- logger.warn(`Failed to activate receipt rule set: ${getErrorMessage(e)}`);
260
- }
261
- logger.success("Email DNS records configured!");
262
- logger.info("Note: DKIM verification may take 5-15 minutes to complete");
263
- } catch (error) {
264
- logger.warn(`Failed to set up email DNS records: ${getErrorMessage(error)}`);
265
- logger.info("You can manually set up DNS records using: buddy email:verify");
266
- }
267
- }
268
- async function createDefaultMailUser(appName, emailDomain, region, logger) {
269
- try {
270
- const { DynamoDBClient } = await import("@stacksjs/ts-cloud"), crypto = await import("crypto"), dynamodb = new DynamoDBClient(region), tableName = `${appName}-mail-users`, mailboxes = emailConfig?.mailboxes || [];
271
- if (mailboxes.length === 0) {
272
- const defaultEmail = `admin@${emailDomain}`, defaultPassword = crypto.randomBytes(16).toString("hex"), passwordHash = crypto.createHash("sha256").update(defaultPassword).digest("hex");
273
- try {
274
- await dynamodb.putItem({
275
- TableName: tableName,
276
- Item: {
277
- email: { S: defaultEmail },
278
- passwordHash: { S: passwordHash },
279
- createdAt: { S: new Date().toISOString() },
280
- displayName: { S: "Admin" }
281
- }
282
- });
283
- logger.success(`Created default mail user: ${defaultEmail}`);
284
- logger.info(`Password: ${defaultPassword}`);
285
- logger.info("Save this password - it will not be shown again!");
286
- } catch (e) {
287
- const msg = getErrorMessage(e);
288
- if (msg.includes("ConditionalCheckFailedException") || msg.includes("already exists"))
289
- logger.debug("Default mail user already exists");
290
- else
291
- throw e;
292
- }
293
- } else
294
- for (const mailbox of mailboxes) {
295
- const mb = mailbox, email = typeof mailbox === "string" ? `${mailbox}@${emailDomain}` : `${mb.name || mb.address?.split("@")[0]}@${emailDomain}`, password = typeof mailbox === "object" && mb.password ? mb.password : crypto.randomBytes(16).toString("hex"), passwordHash = crypto.createHash("sha256").update(password).digest("hex");
296
- try {
297
- await dynamodb.putItem({
298
- TableName: tableName,
299
- Item: {
300
- email: { S: email },
301
- passwordHash: { S: passwordHash },
302
- createdAt: { S: new Date().toISOString() },
303
- displayName: { S: typeof mailbox === "object" ? mb.displayName || mb.name || email : mailbox }
304
- }
305
- });
306
- logger.success(`Created mail user: ${email}`);
307
- if (typeof mailbox !== "object" || !mb.password)
308
- logger.info(` Password: ${password}`);
309
- } catch (e) {
310
- const msg = getErrorMessage(e);
311
- if (msg.includes("ConditionalCheckFailedException"))
312
- logger.debug(`Mail user ${email} already exists`);
313
- else
314
- logger.warn(`Failed to create mail user ${email}: ${msg}`);
315
- }
316
- }
317
- } catch (error) {
318
- logger.warn(`Failed to create mail users: ${getErrorMessage(error)}`);
319
- }
320
- }
321
- async function uploadMailServerToS3(bucketName, region, mode) {
322
- try {
323
- const { S3Client: S3 } = await import("@stacksjs/ts-cloud"), s3Client = new S3(region);
324
- if (mode === "serverless") {
325
- const serverTsPath = p.frameworkPath("core/mail-server/server.ts");
326
- if (existsSync(serverTsPath)) {
327
- const serverCode = readFileSync(serverTsPath, "utf-8");
328
- await s3Client.putObject({
329
- bucket: bucketName,
330
- key: "mail-server/server.ts",
331
- body: serverCode,
332
- contentType: "text/typescript"
333
- });
334
- log.success("Uploaded serverless mail server code to S3");
335
- }
336
- const pkgPath = p.frameworkPath("core/mail-server/package.json");
337
- if (existsSync(pkgPath)) {
338
- const pkgJson = readFileSync(pkgPath, "utf-8");
339
- await s3Client.putObject({
340
- bucket: bucketName,
341
- key: "mail-server/package.json",
342
- body: pkgJson,
343
- contentType: "application/json"
344
- });
345
- }
346
- return;
347
- }
348
- let binaryUploaded = !1;
349
- try {
350
- await installMailBinaryWithPantry();
351
- const linuxBinaryPath = await findPantryMailBinary();
352
- if (linuxBinaryPath && existsSync(linuxBinaryPath) && isElfBinary(linuxBinaryPath)) {
353
- log.info(`Uploading Pantry mail binary: ${linuxBinaryPath}`);
354
- const binaryContent = readFileSync(linuxBinaryPath);
355
- await s3Client.putObject({
356
- bucket: bucketName,
357
- key: "mail-server/smtp-server",
358
- body: binaryContent,
359
- contentType: "application/octet-stream"
360
- });
361
- log.success("Uploaded Linux x86_64 mail server binary to S3");
362
- binaryUploaded = !0;
363
- }
364
- } catch (error) {
365
- log.debug(`Pantry mail install failed: ${getErrorMessage(error)}`);
366
- }
367
- if (!binaryUploaded)
368
- log.warn(`No Pantry-provided ${MAIL_TARGET_PLATFORM} mail binary found. Release ${MAIL_PACKAGE_DOMAIN}, then run the Pantry binary sync for that package.`);
369
- } catch (uploadErr) {
370
- log.debug(`Could not upload mail server to S3 (bucket may not exist yet): ${uploadErr.message}`);
371
- }
372
- }
373
- async function loadTsCloudConfig(envName) {
374
- try {
375
- const base = p.projectPath("config/cloud.ts");
376
- return (await import(envName ? `${base}?env=${envName}` : base)).tsCloud;
377
- } catch (err) {
378
- log.debug("Could not load config/cloud.ts tsCloud export:", err);
379
- return;
380
- }
381
- }
382
- function resolveProvider(tsCloudConfig) {
383
- return tsCloudConfig?.cloud?.provider || process.env.CLOUD_PROVIDER || "aws";
384
- }
385
- function readWaitSecs(name, defaultSecs) {
386
- const secs = process.env[name] ? Number.parseInt(process.env[name], 10) : Number.NaN;
387
- return Number.isFinite(secs) && secs > 0 ? secs : defaultSecs;
388
- }
389
- function fmtDuration(secs) {
390
- const m = Math.floor(secs / 60), s = secs % 60;
391
- return m ? s ? `${m}m${s}s` : `${m}m` : `${s}s`;
392
- }
393
- async function pollUntil(opts) {
394
- log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`);
395
- const started = Date.now(), deadline = started + opts.timeoutSecs * 1000;
396
- let lastHeartbeat = 0;
397
- for (;; )
398
- try {
399
- await opts.check();
400
- return;
401
- } catch {
402
- const elapsedSecs = Math.floor((Date.now() - started) / 1000);
403
- if (Date.now() > deadline)
404
- throw Error(opts.timeoutMessage(elapsedSecs));
405
- if (elapsedSecs - lastHeartbeat >= 30) {
406
- log.info(` \u2026 still waiting (${elapsedSecs}s elapsed)`);
407
- lastHeartbeat = elapsedSecs;
408
- }
409
- await new Promise((r) => setTimeout(r, opts.intervalMs ?? 5000));
410
- }
411
- }
412
- async function waitForRemoteReady(ip) {
413
- const { sshExecOrThrow } = await import("@stacksjs/ts-cloud"), run = (remote) => sshExecOrThrow(ip, remote, { user: "root", connectTimeoutSec: 10 }), sshWaitSecs = readWaitSecs("TS_CLOUD_SSH_WAIT_SECS", 480);
414
- await pollUntil({
415
- label: "Waiting for SSH to come up",
416
- timeoutSecs: sshWaitSecs,
417
- check: () => run("true"),
418
- timeoutMessage: (elapsed) => `SSH did not become reachable on ${ip} within ${fmtDuration(sshWaitSecs)} (waited ${elapsed}s). ` + "The box may still be booting \u2014 raise TS_CLOUD_SSH_WAIT_SECS and retry."
419
- });
420
- log.success("SSH is up");
421
- log.info("Waiting for cloud-init (installing bun + caddy)...");
422
- try {
423
- await run("cloud-init status --wait || true");
424
- } catch (err) {
425
- log.debug("cloud-init status --wait returned non-zero (continuing):", err);
426
- }
427
- const bootWaitSecs = readWaitSecs("TS_CLOUD_BOOT_WAIT_SECS", 720);
428
- await pollUntil({
429
- label: "Waiting for the bun runtime",
430
- timeoutSecs: bootWaitSecs,
431
- check: () => run("test -x /usr/local/bin/bun"),
432
- timeoutMessage: (elapsed) => `bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(bootWaitSecs)} (waited ${elapsed}s). ` + "cloud-init may have failed \u2014 SSH in and check /var/log/cloud-init-output.log; " + "raise TS_CLOUD_BOOT_WAIT_SECS for slow regions."
433
- });
434
- log.success("Server is ready (bun installed)");
435
- }
436
- async function resolveDeclaredTenants() {
437
- try {
438
- const { config } = await import("@stacksjs/config"), tenants = config.cloud?.tenants;
439
- return Array.isArray(tenants) ? tenants.filter((slug) => typeof slug === "string") : [];
440
- } catch {
441
- return [];
442
- }
443
- }
444
- export async function resolveDeployEnvValues(environment, tsCloudConfig) {
445
- const fileName = environment === "production" ? ".env.production" : environment === "staging" ? ".env.staging" : existsSync(p.projectPath(".env.development")) ? ".env.development" : ".env", filePath = p.projectPath(fileName);
446
- if (!existsSync(filePath))
447
- return {};
448
- try {
449
- const { getEnv } = await import("@stacksjs/env"), result = getEnv(void 0, { file: fileName, format: "json" });
450
- if (!result.success || !result.output) {
451
- log.debug(`[deploy] Could not read ${fileName} for site env merging: ${result.error ?? "unknown error"}`);
452
- return {};
453
- }
454
- const parsed = JSON.parse(result.output), values = {};
455
- for (const [key, value] of Object.entries(parsed)) {
456
- if (/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))
457
- continue;
458
- values[key] = String(value);
459
- }
460
- const { foreignTenantKeys, partitionTenantEnv } = await import("@stacksjs/env"), partition = partitionTenantEnv(values, {
461
- self: tsCloudConfig?.project?.slug,
462
- tenants: await resolveDeclaredTenants()
463
- });
464
- for (const { tenant, keys } of foreignTenantKeys(partition))
465
- log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} \u2014 they belong to that tenant's own ` + `repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);
466
- return partition.own;
467
- } catch (error) {
468
- log.debug(`[deploy] Failed to resolve ${fileName} for site env merging:`, error);
469
- return {};
470
- }
471
- }
472
- export function mergeSiteDeployEnv(sites, resolvedDeployEnv) {
473
- return Object.fromEntries(Object.entries(sites).map(([siteName, site]) => {
474
- if (!site)
475
- return [siteName, site];
476
- const base = { ...resolvedDeployEnv };
477
- if (site.port !== void 0)
478
- delete base.PORT;
479
- return [siteName, { ...site, env: { ...base, ...site.env || {} } }];
480
- }));
481
- }
482
- function looksLikeSqliteFile(value) {
483
- return typeof value === "string" && /\.(?:sqlite3?|db)$/i.test(value.trim());
484
- }
485
- export function siteSqlitePath(siteEnv = {}) {
486
- if (String(siteEnv.DB_CONNECTION ?? "sqlite").trim().toLowerCase() !== "sqlite")
487
- return null;
488
- const configured = typeof siteEnv.DB_DATABASE_PATH === "string" && siteEnv.DB_DATABASE_PATH.trim() ? siteEnv.DB_DATABASE_PATH : looksLikeSqliteFile(siteEnv.DB_DATABASE) ? siteEnv.DB_DATABASE : "database/stacks.sqlite", path = String(configured).trim().replace(/^\.\//, "");
489
- if (!path || path.startsWith("/") || path.startsWith("~") || path.split("/").includes(".."))
490
- return null;
491
- return path;
492
- }
493
- export function tsCloudPersistentStateSupport(buildSiteDeployScript) {
494
- const missing = [];
495
- if (typeof buildSiteDeployScript !== "function")
496
- return { ok: !1, missing: ["buildSiteDeployScript"] };
497
- const target = "/var/www/probe-shared/database/probe.sqlite";
498
- let script = "";
499
- try {
500
- script = buildSiteDeployScript({
501
- siteName: "probe",
502
- slug: "probe",
503
- appDir: "/var/www/probe-probe",
504
- artifactFetch: [],
505
- releaseId: "probe",
506
- execStart: "/bin/true",
507
- envEntries: {},
508
- port: 3000,
509
- sharedPaths: [{ path: "database/probe.sqlite", target, seed: !0 }]
510
- }).join(`
511
- `);
512
- } catch {
513
- return { ok: !1, missing: ["shared paths with an explicit target"] };
514
- }
515
- if (!(script.includes("cp -a") && script.includes("/current/")))
516
- missing.push("adoption of existing state into shared/");
517
- if (!script.includes(`ln -sfn ${target} `))
518
- missing.push("shared paths with an explicit target");
519
- return { ok: missing.length === 0, missing };
520
- }
521
- export function projectDatabaseTarget(slug, relativePath) {
522
- return `/var/www/${slug}-shared/${relativePath}`;
523
- }
524
- function runsMigrations(site) {
525
- return Array.isArray(site?.preStart) && site.preStart.some((cmd) => typeof cmd === "string" && /\bmigrate\b/.test(cmd));
526
- }
527
- export function applyPersistentStatePaths(sites, slug) {
528
- const isServerApp = (site) => !!site && typeof site.start === "string", appSites = Object.entries(sites).filter(([, site]) => isServerApp(site)), owner = (appSites.find(([, site]) => runsMigrations(site)) ?? appSites[0])?.[0], out = {};
529
- for (const [name, site] of Object.entries(sites)) {
530
- if (!isServerApp(site)) {
531
- out[name] = site;
532
- continue;
533
- }
534
- const sqlite = siteSqlitePath(site.env || {}), framework = [
535
- "storage/logs"
536
- ];
537
- if (sqlite)
538
- framework.unshift({
539
- path: sqlite,
540
- target: projectDatabaseTarget(slug, sqlite),
541
- seed: name === owner
542
- });
543
- const declared = Array.isArray(site.sharedPaths) ? site.sharedPaths.filter(Boolean) : [], byPath = new Map;
544
- for (const entry of [...declared, ...framework])
545
- byPath.set(typeof entry === "string" ? entry : entry.path, entry);
546
- out[name] = { ...site, sharedPaths: [...byPath.values()] };
547
- }
548
- return out;
549
- }
550
- function prefixHostForEnv(host, prefix) {
551
- if (host.startsWith(`${prefix}.`) || host.startsWith(`www.${prefix}.`))
552
- return host;
553
- if (host.startsWith("www."))
554
- return `www.${prefix}.${host.slice(4)}`;
555
- return `${prefix}.${host}`;
556
- }
557
- export function applyEnvironmentToSites(sites, environment, config) {
558
- const prefix = config?.environments?.[environment]?.domainPrefix;
559
- if (!prefix || environment === "production")
560
- return sites;
561
- const allHosts = [];
562
- for (const site of Object.values(sites)) {
563
- const d = site?.domain;
564
- if (typeof d === "string")
565
- allHosts.push(d);
566
- else if (Array.isArray(d)) {
567
- for (const x of d)
568
- if (typeof x === "string")
569
- allHosts.push(x);
570
- }
571
- }
572
- allHosts.sort((a, b) => b.length - a.length);
573
- const rewrite = (val) => {
574
- let r = val;
575
- for (const h of allHosts) {
576
- const esc = h.replace(/[.]/g, "\\.");
577
- r = r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`, "g"), `//${prefixHostForEnv(h, prefix)}`);
578
- }
579
- return r;
580
- }, out = {};
581
- for (const [name, site] of Object.entries(sites)) {
582
- if (!site) {
583
- out[name] = site;
584
- continue;
585
- }
586
- const s = { ...site };
587
- if (typeof s.domain === "string")
588
- s.domain = prefixHostForEnv(s.domain, prefix);
589
- else if (Array.isArray(s.domain))
590
- s.domain = s.domain.map((d) => typeof d === "string" ? prefixHostForEnv(d, prefix) : d);
591
- if (typeof s.redirect === "string")
592
- s.redirect = rewrite(s.redirect);
593
- if (s.env && typeof s.env === "object") {
594
- const e = { ...s.env };
595
- for (const k of Object.keys(e))
596
- if (typeof e[k] === "string")
597
- e[k] = rewrite(e[k]);
598
- s.env = e;
599
- }
600
- out[name] = s;
601
- }
602
- return out;
603
- }
604
- async function deployToHetzner(tsCloudConfig, deployEnv, options) {
605
- const verbose = options.verbose === !0, environment = deployEnv === "prod" ? "production" : deployEnv, apiToken = tsCloudConfig.hetzner?.apiToken || process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN, persistedAttachBox = resolvePersistedAttachTargetBox(tsCloudConfig, environment);
606
- if (!apiToken && !persistedAttachBox) {
607
- log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");
608
- process.exit(ExitCode.FatalError);
609
- }
610
- const sshPubKey = join(homedir(), ".ssh", "id_ed25519.pub");
611
- if (!existsSync(sshPubKey)) {
612
- log.error(`SSH public key not found at ${sshPubKey}.`);
613
- log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");
614
- log.info("Generate one with: ssh-keygen -t ed25519");
615
- process.exit(ExitCode.FatalError);
616
- }
617
- const { createCloudDriver, deployAllComputeSites, ensureManagementDashboard, resolveSiteKind, buildSiteDeployScript } = await loadTsCloudDeployApi(), support = tsCloudPersistentStateSupport(buildSiteDeployScript);
618
- if (!support.ok) {
619
- log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");
620
- log.error(`Missing: ${support.missing.join(", ")}.`);
621
- log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");
622
- process.exit(ExitCode.FatalError);
623
- }
624
- try {
625
- await runHetznerDeploy({ tsCloudConfig, environment, verbose, docker: options.docker === !0, createCloudDriver, deployAllComputeSites, ensureManagementDashboard, resolveSiteKind, onlySite: options.site || void 0, persistedAttachBox });
626
- } catch (err) {
627
- log.error("Hetzner deploy failed:");
628
- console.error(err instanceof Error ? err.stack || err.message : err);
629
- process.exit(ExitCode.FatalError);
630
- }
631
- }
632
- export async function loadTsCloudDeployApi() {
633
- const requested = process.env.TS_CLOUD_MODULE?.trim();
634
- if (!requested)
635
- return import("@stacksjs/ts-cloud");
636
- const modulePath = isAbsolute(requested) ? requested : resolve(process.cwd(), requested);
637
- if (!existsSync(modulePath))
638
- throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);
639
- return import(pathToFileURL(modulePath).href);
640
- }
641
- export function shouldInjectManagementDashboard(tsCloudConfig) {
642
- return !tsCloudConfig.cloud?.attachTo;
643
- }
644
- export function reconcilePartialDeployManagementDashboards(tsCloudConfig, livePorts) {
645
- const sites = tsCloudConfig.sites, preserved = [], removed = [];
646
- if (!sites)
647
- return { preserved, removed };
648
- for (const [siteName, site] of Object.entries(sites)) {
649
- if (siteName !== "dashboard" && !siteName.startsWith("dashboard-"))
650
- continue;
651
- const livePort = livePorts[siteName];
652
- if (typeof livePort !== "number" || !Number.isInteger(livePort) || livePort < 1 || livePort > 65535) {
653
- delete sites[siteName];
654
- removed.push(siteName);
655
- continue;
656
- }
657
- const next = { ...site, port: livePort };
658
- if (typeof next.start === "string")
659
- next.start = next.start.replace(/(--port(?:=|\s+))\d+/, `$1${livePort}`);
660
- sites[siteName] = next;
661
- preserved.push(siteName);
662
- }
663
- return { preserved, removed };
664
- }
665
- async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig, ip) {
666
- const slug = String(tsCloudConfig.project?.slug || "app").replace(/[^a-z0-9._-]+/gi, "-"), siteNames = Object.keys(tsCloudConfig.sites ?? {}).filter((name) => name === "dashboard" || name.startsWith("dashboard-"));
667
- if (siteNames.length === 0)
668
- return;
669
- const units = siteNames.map((siteName) => ({
670
- siteName,
671
- unit: `${slug}-${siteName}.service`
672
- })), probe = `
1
+ import{existsSync,mkdirSync,readFileSync,readdirSync,statSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{dirname,isAbsolute,join,relative,resolve}from"node:path";import process from"node:process";import{pathToFileURL}from"node:url";import{runAction}from"@stacksjs/actions";import{italic,onUnknownSubcommand,outro,prompts,runCommand}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig,cloud as cloudConfig}from"@stacksjs/config";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{encryptEnv,env}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{getErrorCode,getErrorMessage}from"@stacksjs/utils";import{ensureAppKey,ensureEnvIsSet}from"./setup";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args),debug:(...args)=>{if(process.argv.includes("--verbose")||process.argv.includes("-v"))console.log("\uD83D\uDD0D",...args)}},MAIL_PACKAGE_DOMAIN="github.com/mail-os/mail",MAIL_PACKAGE_SPEC=`${MAIL_PACKAGE_DOMAIN}@0.1.0`,MAIL_TARGET_PLATFORM="linux-x86_64",MAIL_BINARY_NAMES=["mail","mail-x86_64-linux","mail-x86_64-linux-gnu"];function collectMatchingFiles(root,names,maxDepth=8){const nameSet=new Set(names),matches=[];if(!existsSync(root))return matches;function walk(dir,depth){if(depth>maxDepth)return;for(const entry of readdirSync(dir)){if(entry===".git"||entry==="node_modules")continue;const fullPath=join(dir,entry),stat=statSync(fullPath);if(stat.isDirectory())walk(fullPath,depth+1);else if(stat.isFile()&&nameSet.has(entry))matches.push(fullPath)}}walk(root,0);return matches}function isElfBinary(filePath){const header=readFileSync(filePath).slice(0,4);return header[0]===127&&header[1]===69&&header[2]===76&&header[3]===70}function resolvePantryInstallCommand(){const localPantryCli=join(homedir(),"Code","Tools","pantry","packages","ts-pantry","bin","cli.ts");if(existsSync(localPantryCli))return{command:"bun",args:[localPantryCli]};const projectPantry=p.projectPath("pantry/.bin/pantry");if(existsSync(projectPantry))return{command:projectPantry,args:[]};const globalPantry=join(homedir(),".local","share","pantry","global","bin","pantry");if(existsSync(globalPantry))return{command:globalPantry,args:[]};return{command:"pantry",args:[]}}async function installMailBinaryWithPantry(){const{execFileSync}=await import("node:child_process"),pantry=resolvePantryInstallCommand();execFileSync(pantry.command,[...pantry.args,"install",MAIL_PACKAGE_SPEC,"--install-dir",p.projectPath("pantry"),"--platform",MAIL_TARGET_PLATFORM,"--quiet"],{cwd:p.projectPath(),stdio:process.argv.includes("--verbose")||process.argv.includes("-v")?"inherit":"pipe",env:process.env})}async function findPantryMailBinary(){const directCandidates=[...MAIL_BINARY_NAMES.map((name)=>p.projectPath(`pantry/.bin/${name}`)),...MAIL_BINARY_NAMES.map((name)=>join(homedir(),".local","share","pantry","global","bin",name))];for(const candidate of directCandidates)if(existsSync(candidate)&&isElfBinary(candidate))return candidate;for(const root of[p.projectPath("pantry"),join(homedir(),".local","share","pantry")])for(const candidate of collectMatchingFiles(root,MAIL_BINARY_NAMES))if(isElfBinary(candidate))return candidate;return null}async function ensureDeployPrerequisites(verbose=!1){const cwd=p.projectPath();await ensureEnvIsSet({cwd,verbose});await ensureAppKey(cwd)}function loadAwsCredentialsFromFile(){const credentialsPath=join(homedir(),".aws","credentials"),configPath=join(homedir(),".aws","config");if(!existsSync(credentialsPath))return{};try{const lines=readFileSync(credentialsPath,"utf-8").split(`
2
+ `),profiles=["default","stacks"];let currentProfile="",credentials={};const profileCredentials={};for(const line of lines){const trimmed=line.trim(),profileMatch=trimmed.match(/^\[(.+)\]$/);if(profileMatch?.[1]){currentProfile=profileMatch[1];profileCredentials[currentProfile]={};continue}const keyValue=trimmed.match(/^(\w+)\s*=\s*(.+)$/);if(keyValue&&currentProfile){const[,key,value]=keyValue,target=profileCredentials[currentProfile];if(!target||value===void 0)continue;if(key==="aws_access_key_id")target.accessKeyId=value;else if(key==="aws_secret_access_key")target.secretAccessKey=value}}for(const profile of profiles)if(profileCredentials[profile]?.accessKeyId&&profileCredentials[profile]?.secretAccessKey){credentials=profileCredentials[profile];log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}if(!credentials.accessKeyId){for(const[profile,creds]of Object.entries(profileCredentials))if(creds.accessKeyId&&creds.secretAccessKey){credentials=creds;log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}}let region;if(existsSync(configPath)){const regionMatch=readFileSync(configPath,"utf-8").match(/region\s*=\s*(.+)/);if(regionMatch?.[1])region=regionMatch[1].trim()}return{...credentials,region}}catch(error){log.debug("Failed to read AWS credentials file:",error);return{}}}async function setupEmailDnsRecords(emailDomain,region,logger,options){logger.info("Setting up email DNS records...");try{const{SESClient}=await import("@stacksjs/ts-cloud"),{Route53Client}=await import("@stacksjs/ts-cloud"),ses=new SESClient(region),route53=new Route53Client(region);logger.info(`Getting DKIM tokens for ${emailDomain}...`);const tokens=(await ses.getEmailIdentity(emailDomain)).DkimAttributes?.Tokens||[];if(tokens.length===0){logger.warn("No DKIM tokens found - domain may not be set up in SES yet");return}logger.info(`Found ${tokens.length} DKIM tokens`);const zone=(await route53.listHostedZones()).HostedZones?.find((z)=>z.Name===`${emailDomain}.`);if(!zone){logger.warn(`Hosted zone not found for ${emailDomain} - DNS records must be added manually`);logger.info("DKIM records needed:");for(const token of tokens)logger.info(` CNAME: ${token}._domainkey.${emailDomain} -> ${token}.dkim.amazonses.com`);logger.info(` MX: ${emailDomain} -> 10 inbound-smtp.${region}.amazonaws.com`);return}const hostedZoneId=zone.Id?.replace("/hostedzone/","");logger.info(`Found hosted zone: ${hostedZoneId}`);for(const token of tokens){const recordName=`${token}._domainkey.${emailDomain}`,recordValue=`${token}.dkim.amazonses.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:recordName,Type:"CNAME",TTL:300,ResourceRecords:[{Value:recordValue}]}}]}});logger.success(`Added DKIM record: ${token}._domainkey`)}catch(e){logger.warn(`Failed to add DKIM record: ${getErrorMessage(e)}`)}}const mailSubdomain=options?.mailSubdomain||"mail",mxTarget=options?.mode==="server"?`10 ${mailSubdomain}.${emailDomain}`:`10 inbound-smtp.${region}.amazonaws.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"MX",TTL:300,ResourceRecords:[{Value:mxTarget}]}}]}});logger.success(`Added MX record: ${mxTarget}`)}catch(e){logger.warn(`Failed to add MX record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"TXT",TTL:300,ResourceRecords:[{Value:'"v=spf1 include:amazonses.com ~all"'}]}}]}});logger.success("Added SPF record")}catch(e){logger.warn(`Failed to add SPF record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:`_dmarc.${emailDomain}`,Type:"TXT",TTL:300,ResourceRecords:[{Value:`"v=DMARC1;p=quarantine;pct=25;rua=mailto:dmarcreports@${emailDomain}"`}]}}]}});logger.success("Added DMARC record")}catch(e){logger.warn(`Failed to add DMARC record: ${getErrorMessage(e)}`)}try{const ruleSetName=`${process.env.APP_NAME?.toLowerCase().replace(/[^a-z0-9]/g,"-")||"stacks"}-email-rules`;await ses.setActiveReceiptRuleSet(ruleSetName);logger.success(`Activated email receipt rule set: ${ruleSetName}`)}catch(e){logger.warn(`Failed to activate receipt rule set: ${getErrorMessage(e)}`)}logger.success("Email DNS records configured!");logger.info("Note: DKIM verification may take 5-15 minutes to complete")}catch(error){logger.warn(`Failed to set up email DNS records: ${getErrorMessage(error)}`);logger.info("You can manually set up DNS records using: buddy email:verify")}}async function createDefaultMailUser(appName,emailDomain,region,logger){try{const{DynamoDBClient}=await import("@stacksjs/ts-cloud"),crypto=await import("crypto"),dynamodb=new DynamoDBClient(region),tableName=`${appName}-mail-users`,mailboxes=emailConfig?.mailboxes||[];if(mailboxes.length===0){const defaultEmail=`admin@${emailDomain}`,defaultPassword=crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(defaultPassword).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:defaultEmail},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:"Admin"}}});logger.success(`Created default mail user: ${defaultEmail}`);logger.info(`Password: ${defaultPassword}`);logger.info("Save this password - it will not be shown again!")}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException")||msg.includes("already exists"))logger.debug("Default mail user already exists");else throw e}}else for(const mailbox of mailboxes){const mb=mailbox,email=typeof mailbox==="string"?`${mailbox}@${emailDomain}`:`${mb.name||mb.address?.split("@")[0]}@${emailDomain}`,password=typeof mailbox==="object"&&mb.password?mb.password:crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(password).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:email},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:typeof mailbox==="object"?mb.displayName||mb.name||email:mailbox}}});logger.success(`Created mail user: ${email}`);if(typeof mailbox!=="object"||!mb.password)logger.info(` Password: ${password}`)}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException"))logger.debug(`Mail user ${email} already exists`);else logger.warn(`Failed to create mail user ${email}: ${msg}`)}}}catch(error){logger.warn(`Failed to create mail users: ${getErrorMessage(error)}`)}}async function uploadMailServerToS3(bucketName,region,mode){try{const{S3Client:S3}=await import("@stacksjs/ts-cloud"),s3Client=new S3(region);if(mode==="serverless"){const serverTsPath=p.frameworkPath("core/mail-server/server.ts");if(existsSync(serverTsPath)){const serverCode=readFileSync(serverTsPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/server.ts",body:serverCode,contentType:"text/typescript"});log.success("Uploaded serverless mail server code to S3")}const pkgPath=p.frameworkPath("core/mail-server/package.json");if(existsSync(pkgPath)){const pkgJson=readFileSync(pkgPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/package.json",body:pkgJson,contentType:"application/json"})}return}let binaryUploaded=!1;try{await installMailBinaryWithPantry();const linuxBinaryPath=await findPantryMailBinary();if(linuxBinaryPath&&existsSync(linuxBinaryPath)&&isElfBinary(linuxBinaryPath)){log.info(`Uploading Pantry mail binary: ${linuxBinaryPath}`);const binaryContent=readFileSync(linuxBinaryPath);await s3Client.putObject({bucket:bucketName,key:"mail-server/smtp-server",body:binaryContent,contentType:"application/octet-stream"});log.success("Uploaded Linux x86_64 mail server binary to S3");binaryUploaded=!0}}catch(error){log.debug(`Pantry mail install failed: ${getErrorMessage(error)}`)}if(!binaryUploaded)log.warn(`No Pantry-provided ${MAIL_TARGET_PLATFORM} mail binary found. Release ${MAIL_PACKAGE_DOMAIN}, then run the Pantry binary sync for that package.`)}catch(uploadErr){log.debug(`Could not upload mail server to S3 (bucket may not exist yet): ${uploadErr.message}`)}}async function loadTsCloudConfig(envName){try{const base=p.projectPath("config/cloud.ts");return(await import(envName?`${base}?env=${envName}`:base)).tsCloud}catch(err){log.debug("Could not load config/cloud.ts tsCloud export:",err);return}}function resolveProvider(tsCloudConfig){return tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws"}function readWaitSecs(name,defaultSecs){const secs=process.env[name]?Number.parseInt(process.env[name],10):Number.NaN;return Number.isFinite(secs)&&secs>0?secs:defaultSecs}function fmtDuration(secs){const m=Math.floor(secs/60),s=secs%60;return m?s?`${m}m${s}s`:`${m}m`:`${s}s`}async function pollUntil(opts){log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`);const started=Date.now(),deadline=started+opts.timeoutSecs*1000;let lastHeartbeat=0;for(;;)try{await opts.check();return}catch{const elapsedSecs=Math.floor((Date.now()-started)/1000);if(Date.now()>deadline)throw Error(opts.timeoutMessage(elapsedSecs));if(elapsedSecs-lastHeartbeat>=30){log.info(` \u2026 still waiting (${elapsedSecs}s elapsed)`);lastHeartbeat=elapsedSecs}await new Promise((r)=>setTimeout(r,opts.intervalMs??5000))}}async function waitForRemoteReady(ip){const{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),run=(remote)=>sshExecOrThrow(ip,remote,{user:"root",connectTimeoutSec:10}),sshWaitSecs=readWaitSecs("TS_CLOUD_SSH_WAIT_SECS",480);await pollUntil({label:"Waiting for SSH to come up",timeoutSecs:sshWaitSecs,check:()=>run("true"),timeoutMessage:(elapsed)=>`SSH did not become reachable on ${ip} within ${fmtDuration(sshWaitSecs)} (waited ${elapsed}s). `+"The box may still be booting \u2014 raise TS_CLOUD_SSH_WAIT_SECS and retry."});log.success("SSH is up");log.info("Waiting for cloud-init (installing bun + caddy)...");try{await run("cloud-init status --wait || true")}catch(err){log.debug("cloud-init status --wait returned non-zero (continuing):",err)}const bootWaitSecs=readWaitSecs("TS_CLOUD_BOOT_WAIT_SECS",720);await pollUntil({label:"Waiting for the bun runtime",timeoutSecs:bootWaitSecs,check:()=>run("test -x /usr/local/bin/bun"),timeoutMessage:(elapsed)=>`bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(bootWaitSecs)} (waited ${elapsed}s). `+"cloud-init may have failed \u2014 SSH in and check /var/log/cloud-init-output.log; "+"raise TS_CLOUD_BOOT_WAIT_SECS for slow regions."});log.success("Server is ready (bun installed)")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),tenants=config.cloud?.tenants;return Array.isArray(tenants)?tenants.filter((slug)=>typeof slug==="string"):[]}catch{return[]}}export async function resolveDeployEnvValues(environment,tsCloudConfig){const fileName=environment==="production"?".env.production":environment==="staging"?".env.staging":existsSync(p.projectPath(".env.development"))?".env.development":".env",filePath=p.projectPath(fileName);if(!existsSync(filePath))return{};try{const{getEnv}=await import("@stacksjs/env"),result=getEnv(void 0,{file:fileName,format:"json"});if(!result.success||!result.output){log.debug(`[deploy] Could not read ${fileName} for site env merging: ${result.error??"unknown error"}`);return{}}const parsed=JSON.parse(result.output),values={};for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value)}const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),partition=partitionTenantEnv(values,{self:tsCloudConfig?.project?.slug,tenants:await resolveDeclaredTenants()});for(const{tenant,keys}of foreignTenantKeys(partition))log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} \u2014 they belong to that tenant's own `+`repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);return partition.own}catch(error){log.debug(`[deploy] Failed to resolve ${fileName} for site env merging:`,error);return{}}}export function mergeSiteDeployEnv(sites,resolvedDeployEnv){return Object.fromEntries(Object.entries(sites).map(([siteName,site])=>{if(!site)return[siteName,site];const base={...resolvedDeployEnv};if(site.port!==void 0)delete base.PORT;return[siteName,{...site,env:{...base,...site.env||{}}}]}))}function looksLikeSqliteFile(value){return typeof value==="string"&&/\.(?:sqlite3?|db)$/i.test(value.trim())}export function siteSqlitePath(siteEnv={}){if(String(siteEnv.DB_CONNECTION??"sqlite").trim().toLowerCase()!=="sqlite")return null;const configured=typeof siteEnv.DB_DATABASE_PATH==="string"&&siteEnv.DB_DATABASE_PATH.trim()?siteEnv.DB_DATABASE_PATH:looksLikeSqliteFile(siteEnv.DB_DATABASE)?siteEnv.DB_DATABASE:"database/stacks.sqlite",path=String(configured).trim().replace(/^\.\//,"");if(!path||path.startsWith("/")||path.startsWith("~")||path.split("/").includes(".."))return null;return path}export function tsCloudPersistentStateSupport(buildSiteDeployScript){const missing=[];if(typeof buildSiteDeployScript!=="function")return{ok:!1,missing:["buildSiteDeployScript"]};const target="/var/www/probe-shared/database/probe.sqlite";let script="";try{script=buildSiteDeployScript({siteName:"probe",slug:"probe",appDir:"/var/www/probe-probe",artifactFetch:[],releaseId:"probe",execStart:"/bin/true",envEntries:{},port:3000,sharedPaths:[{path:"database/probe.sqlite",target,seed:!0}]}).join(`
3
+ `)}catch{return{ok:!1,missing:["shared paths with an explicit target"]}}if(!(script.includes("cp -a")&&script.includes("/current/")))missing.push("adoption of existing state into shared/");if(!script.includes(`ln -sfn ${target} `))missing.push("shared paths with an explicit target");return{ok:missing.length===0,missing}}export function projectDatabaseTarget(slug,relativePath){return`/var/www/${slug}-shared/${relativePath}`}function runsMigrations(site){return Array.isArray(site?.preStart)&&site.preStart.some((cmd)=>typeof cmd==="string"&&/\bmigrate\b/.test(cmd))}export function applyPersistentStatePaths(sites,slug){const isServerApp=(site)=>!!site&&typeof site.start==="string",appSites=Object.entries(sites).filter(([,site])=>isServerApp(site)),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0],out={};for(const[name,site]of Object.entries(sites)){if(!isServerApp(site)){out[name]=site;continue}const sqlite=siteSqlitePath(site.env||{}),framework=["storage/logs"];if(sqlite)framework.unshift({path:sqlite,target:projectDatabaseTarget(slug,sqlite),seed:name===owner});const declared=Array.isArray(site.sharedPaths)?site.sharedPaths.filter(Boolean):[],byPath=new Map;for(const entry of[...declared,...framework])byPath.set(typeof entry==="string"?entry:entry.path,entry);out[name]={...site,sharedPaths:[...byPath.values()]}}return out}function prefixHostForEnv(host,prefix){if(host.startsWith(`${prefix}.`)||host.startsWith(`www.${prefix}.`))return host;if(host.startsWith("www."))return`www.${prefix}.${host.slice(4)}`;return`${prefix}.${host}`}export function applyEnvironmentToSites(sites,environment,config){const prefix=config?.environments?.[environment]?.domainPrefix;if(!prefix||environment==="production")return sites;const allHosts=[];for(const site of Object.values(sites)){const d=site?.domain;if(typeof d==="string")allHosts.push(d);else if(Array.isArray(d)){for(const x of d)if(typeof x==="string")allHosts.push(x)}}allHosts.sort((a,b)=>b.length-a.length);const rewrite=(val)=>{let r=val;for(const h of allHosts){const esc=h.replace(/[.]/g,"\\.");r=r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`,"g"),`//${prefixHostForEnv(h,prefix)}`)}return r},out={};for(const[name,site]of Object.entries(sites)){if(!site){out[name]=site;continue}const s={...site};if(typeof s.domain==="string")s.domain=prefixHostForEnv(s.domain,prefix);else if(Array.isArray(s.domain))s.domain=s.domain.map((d)=>typeof d==="string"?prefixHostForEnv(d,prefix):d);if(typeof s.redirect==="string")s.redirect=rewrite(s.redirect);if(s.env&&typeof s.env==="object"){const e={...s.env};for(const k of Object.keys(e))if(typeof e[k]==="string")e[k]=rewrite(e[k]);s.env=e}out[name]=s}return out}async function deployToHetzner(tsCloudConfig,deployEnv,options){const verbose=options.verbose===!0,environment=deployEnv==="prod"?"production":deployEnv,apiToken=tsCloudConfig.hetzner?.apiToken||process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN,persistedAttachBox=resolvePersistedAttachTargetBox(tsCloudConfig,environment);if(!apiToken&&!persistedAttachBox){log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");process.exit(ExitCode.FatalError)}const sshPubKey=join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(sshPubKey)){log.error(`SSH public key not found at ${sshPubKey}.`);log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");log.info("Generate one with: ssh-keygen -t ed25519");process.exit(ExitCode.FatalError)}const{createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,buildSiteDeployScript}=await loadTsCloudDeployApi(),support=tsCloudPersistentStateSupport(buildSiteDeployScript);if(!support.ok){log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");log.error(`Missing: ${support.missing.join(", ")}.`);log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}try{await runHetznerDeploy({tsCloudConfig,environment,verbose,docker:options.docker===!0,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite:options.site||void 0,persistedAttachBox})}catch(err){log.error("Hetzner deploy failed:");console.error(err instanceof Error?err.stack||err.message:err);process.exit(ExitCode.FatalError)}}export function resolveProjectTsCloudModule(projectRoot=process.cwd()){const manifestPath=join(projectRoot,"node_modules","@stacksjs","ts-cloud","package.json");if(!existsSync(manifestPath))return;const manifest=JSON.parse(readFileSync(manifestPath,"utf8")),rootExport=manifest.exports?.["."],entry=manifest.module??(typeof rootExport==="string"?rootExport:rootExport?.import);if(!entry)return;const modulePath=resolve(dirname(manifestPath),entry);return existsSync(modulePath)?modulePath:void 0}export async function loadTsCloudDeployApi(){const requested=process.env.TS_CLOUD_MODULE?.trim();if(!requested){const projectModule=resolveProjectTsCloudModule();return projectModule?import(pathToFileURL(projectModule).href):import("@stacksjs/ts-cloud")}const modulePath=isAbsolute(requested)?requested:resolve(process.cwd(),requested);if(!existsSync(modulePath))throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);return import(pathToFileURL(modulePath).href)}export function shouldInjectManagementDashboard(tsCloudConfig){return!tsCloudConfig.cloud?.attachTo}export function reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts){const sites=tsCloudConfig.sites,preserved=[],removed=[];if(!sites)return{preserved,removed};for(const[siteName,site]of Object.entries(sites)){if(siteName!=="dashboard"&&!siteName.startsWith("dashboard-"))continue;const livePort=livePorts[siteName];if(typeof livePort!=="number"||!Number.isInteger(livePort)||livePort<1||livePort>65535){delete sites[siteName];removed.push(siteName);continue}const next={...site,port:livePort};if(typeof next.start==="string")next.start=next.start.replace(/(--port(?:=|\s+))\d+/,`$1${livePort}`);sites[siteName]=next;preserved.push(siteName)}return{preserved,removed}}async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,ip){const slug=String(tsCloudConfig.project?.slug||"app").replace(/[^a-z0-9._-]+/gi,"-"),siteNames=Object.keys(tsCloudConfig.sites??{}).filter((name)=>name==="dashboard"||name.startsWith("dashboard-"));if(siteNames.length===0)return;const units=siteNames.map((siteName)=>({siteName,unit:`${slug}-${siteName}.service`})),probe=`
673
4
  const units = ${JSON.stringify(units)}
674
5
  const text = bytes => new TextDecoder().decode(bytes).trim()
675
6
  const run = args => text(Bun.spawnSync(args).stdout)
@@ -683,378 +14,12 @@ for (const entry of units) {
683
14
  ports[entry.siteName] = Number(match[1])
684
15
  }
685
16
  console.log(JSON.stringify(ports))
686
- `.trim(), encoded = Buffer.from(probe).toString("base64"), { sshExecOrThrow } = await import("@stacksjs/ts-cloud"), line = (await sshExecOrThrow(ip, `/usr/local/bin/bun -e "eval(Buffer.from('${encoded}','base64').toString())"`, { user: "root", connectTimeoutSec: 10 })).trim().split(`
687
- `).at(-1) || "{}", livePorts = JSON.parse(line), result = reconcilePartialDeployManagementDashboards(tsCloudConfig, livePorts);
688
- for (const siteName of result.preserved)
689
- log.info(`Partial deploy: preserving the live management dashboard service '${siteName}' on port ${livePorts[siteName]}`);
690
- for (const siteName of result.removed)
691
- log.info(`Partial deploy: omitting management dashboard route '${siteName}' because no active service owns it`);
692
- }
693
- export function resolvePersistedAttachTargetBox(tsCloudConfig, environment, cwd = process.cwd()) {
694
- const owner = tsCloudConfig.cloud?.attachTo;
695
- if (!owner)
696
- return null;
697
- const stackName = tsCloudConfig.project?.stackName || `${tsCloudConfig.project?.slug || "app"}-${environment}`, statePath = join(cwd, "storage", "cloud", "state", `${stackName}.json`);
698
- if (!existsSync(statePath))
699
- return null;
700
- try {
701
- const state = JSON.parse(readFileSync(statePath, "utf8"));
702
- if (state.stackName !== stackName || typeof state.serverId !== "number" || typeof state.publicIp !== "string" || !state.publicIp.trim())
703
- return null;
704
- return {
705
- serverId: state.serverId,
706
- serverName: typeof state.serverName === "string" && state.serverName ? state.serverName : `${owner}-${environment}-app`,
707
- publicIp: state.publicIp,
708
- publicIpv6: typeof state.publicIpv6 === "string" ? state.publicIpv6 : void 0
709
- };
710
- } catch {
711
- return null;
712
- }
713
- }
714
- export function scrubLoopbackSitePortsForFirewall(tsCloudConfig) {
715
- const sites = tsCloudConfig?.sites;
716
- if (!sites)
717
- return tsCloudConfig;
718
- const loopbackHosts = new Set(["127.0.0.1", "::1", "localhost"]), scrubbed = {};
719
- for (const [siteName, site] of Object.entries(sites)) {
720
- const host = String(site?.env?.HOST ?? "").toLowerCase();
721
- if (site && typeof site.port === "number" && !site.domain && loopbackHosts.has(host)) {
722
- const rest = { ...site };
723
- delete rest.port;
724
- scrubbed[siteName] = rest;
725
- } else
726
- scrubbed[siteName] = site;
727
- }
728
- return { ...tsCloudConfig, sites: scrubbed };
729
- }
730
- function githubDeploymentsEnabled() {
731
- return process.env.GITHUB_ACTIONS !== "true" && process.env.TS_CLOUD_GITHUB_DEPLOYMENTS !== "0";
732
- }
733
- async function ghCliAvailable() {
734
- try {
735
- const { execSync } = await import("node:child_process");
736
- execSync("gh --version", { stdio: "ignore" });
737
- return !0;
738
- } catch {
739
- return !1;
740
- }
741
- }
742
- async function resolveSiteGithubSource(root) {
743
- try {
744
- const { execSync } = await import("node:child_process"), run = (cmd) => execSync(cmd, { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(), match = run("git config --get remote.origin.url").match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
745
- if (!match?.[1])
746
- return null;
747
- return { repo: match[1], ref: run("git rev-parse HEAD") };
748
- } catch {
749
- return null;
750
- }
751
- }
752
- async function setGithubDeploymentStatus(record, state) {
753
- try {
754
- const { execSync } = await import("node:child_process"), body = JSON.stringify({
755
- state,
756
- environment: record.environment,
757
- ...record.environmentUrl ? { environment_url: record.environmentUrl } : {},
758
- description: state === "success" ? "Deployed" : state === "failure" ? "Deploy failed" : "Deploying"
759
- });
760
- execSync(`gh api -X POST repos/${record.repo}/deployments/${record.id}/statuses --input -`, { input: body, stdio: ["pipe", "ignore", "ignore"] });
761
- } catch (err) {
762
- log.warn(`GitHub deployment status (${state}) skipped for ${record.repo}: ${getErrorMessage(err)}`);
763
- }
764
- }
765
- async function startGithubDeployment(source, environment, environmentUrl) {
766
- try {
767
- const { execSync } = await import("node:child_process"), body = JSON.stringify({
768
- ref: source.ref,
769
- environment,
770
- description: `buddy deploy (${environment})`,
771
- auto_merge: !1,
772
- required_contexts: [],
773
- production_environment: environment === "production"
774
- }), out = execSync(`gh api -X POST repos/${source.repo}/deployments --input - --jq '.id'`, { input: body, stdio: ["pipe", "pipe", "ignore"] }).toString().trim(), id = Number(out);
775
- if (!out || !Number.isInteger(id) || id <= 0)
776
- return null;
777
- const record = { repo: source.repo, id, environment, environmentUrl };
778
- await setGithubDeploymentStatus(record, "in_progress");
779
- return record;
780
- } catch (err) {
781
- log.warn(`GitHub deployment record skipped for ${source.repo}: ${getErrorMessage(err)}`);
782
- return null;
783
- }
784
- }
785
- async function startGithubDeployments(args) {
786
- const { sites, onlySite, environment, resolveSiteKind } = args, records = [];
787
- if (!githubDeploymentsEnabled() || !await ghCliAvailable())
788
- return records;
789
- const seen = new Set;
790
- for (const [siteName, site] of Object.entries(sites)) {
791
- if (!site || onlySite && siteName !== onlySite)
792
- continue;
793
- const kind = resolveSiteKind(site);
794
- if (kind === "bucket" || kind === "redirect")
795
- continue;
796
- const source = await resolveSiteGithubSource(site.root || ".");
797
- if (!source)
798
- continue;
799
- const key = `${source.repo}@${source.ref}`;
800
- if (seen.has(key))
801
- continue;
802
- seen.add(key);
803
- const record = await startGithubDeployment(source, environment, site.domain ? `https://${site.domain}` : void 0);
804
- if (record) {
805
- records.push(record);
806
- log.info(`GitHub deployment ${record.repo}#${record.id} \u2192 ${environment}`);
807
- }
808
- }
809
- return records;
810
- }
811
- async function runHetznerDeploy(args) {
812
- const { tsCloudConfig, environment, verbose, docker, createCloudDriver, deployAllComputeSites, ensureManagementDashboard, resolveSiteKind, onlySite, persistedAttachBox } = args, startTime = performance.now();
813
- console.log("");
814
- console.log("\uD83D\uDE80 Deploy \u2192 Hetzner Cloud");
815
- console.log("");
816
- log.info(`Project: ${tsCloudConfig.project?.slug}`);
817
- log.info(`Environment: ${environment}`);
818
- log.info(`Location: ${tsCloudConfig.hetzner?.location || process.env.HCLOUD_LOCATION || "fsn1"}`);
819
- log.info(`Size: ${tsCloudConfig.infrastructure?.compute?.size || "small"}`);
820
- try {
821
- if (shouldInjectManagementDashboard(tsCloudConfig) && typeof ensureManagementDashboard === "function")
822
- ensureManagementDashboard(tsCloudConfig, {
823
- cwd: process.cwd(),
824
- logger: { info: (m) => log.info(m), warn: (m) => log.warn(m) }
825
- });
826
- else if (tsCloudConfig.cloud?.attachTo)
827
- log.info(`Management dashboard: using the '${tsCloudConfig.cloud.attachTo}' server owner's dashboard`);
828
- } catch (err) {
829
- log.warn(`Management dashboard injection skipped: ${getErrorMessage(err)}`);
830
- }
831
- const driver = createCloudDriver({ config: tsCloudConfig, provider: "hetzner" });
832
- if (!driver.provisionComputeInfrastructure) {
833
- log.error("Hetzner driver does not support compute provisioning (update @stacksjs/ts-cloud).");
834
- process.exit(ExitCode.FatalError);
835
- }
836
- const attachTo = tsCloudConfig.cloud?.attachTo;
837
- let ip, ipv6;
838
- if (attachTo) {
839
- const box = persistedAttachBox ?? await resolveAttachTargetBox(attachTo, environment);
840
- if (!box?.publicIp) {
841
- log.error(`Attach target '${attachTo}' has no reachable box for '${environment}'. Is '${attachTo}-${environment}-app' provisioned (by its owner)?`);
842
- process.exit(ExitCode.FatalError);
843
- }
844
- ip = box.publicIp;
845
- ipv6 = box.publicIpv6;
846
- log.info(`Attaching to '${attachTo}' box '${box.serverName}' (${ip}) \u2014 skipping provisioning`);
847
- const compute = (tsCloudConfig.infrastructure ??= {}).compute ??= {};
848
- compute.webServer = "rpx";
849
- compute.proxy = { onDemandTls: !0, ...compute.proxy ?? {}, engine: "rpx" };
850
- const stackName = tsCloudConfig.project?.stackName || `${tsCloudConfig.project?.slug || "app"}-${environment}`, stateDir = join(process.cwd(), "storage", "cloud", "state");
851
- mkdirSync(stateDir, { recursive: !0 });
852
- writeFileSync(join(stateDir, `${stackName}.json`), `${JSON.stringify({
853
- stackName,
854
- serverId: box.serverId,
855
- serverName: box.serverName,
856
- publicIp: ip,
857
- sshUser: "root",
858
- deployStoragePath: "/var/ts-cloud/staging"
859
- }, null, 2)}
860
- `);
861
- } else {
862
- log.info("Provisioning Hetzner compute infrastructure...");
863
- const outputs = await driver.provisionComputeInfrastructure({ config: scrubLoopbackSitePortsForFirewall(tsCloudConfig), environment });
864
- ip = outputs.appPublicIp;
865
- ipv6 = outputs.appPublicIpv6;
866
- log.success("Hetzner compute infrastructure ready");
867
- if (outputs.appInstanceId)
868
- log.info(`Server ID: ${outputs.appInstanceId}`);
869
- }
870
- if (ip)
871
- log.info(`Server IP: ${ip}`);
872
- if (!ip) {
873
- log.error("Provisioned server has no public IP \u2014 cannot deploy over SSH.");
874
- process.exit(ExitCode.FatalError);
875
- }
876
- await waitForRemoteReady(ip);
877
- if (onlySite && !attachTo)
878
- await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig, ip);
879
- const { execSync } = await import("node:child_process"), { tmpdir } = await import("node:os"), sites = applyEnvironmentToSites(tsCloudConfig.sites || {}, environment, tsCloudConfig), slug = tsCloudConfig.project?.slug || "app";
880
- let sha;
881
- try {
882
- sha = execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
883
- } catch {
884
- sha = Date.now().toString(36);
885
- }
886
- const tarExcludes = [
887
- "node_modules",
888
- "pantry",
889
- ".git",
890
- ".github",
891
- ".cache",
892
- "bin",
893
- "dist",
894
- relative(p.projectPath(), p.stxPath()).replace(/\/$/, ""),
895
- ".stx",
896
- relative(p.projectPath(), p.cloudStatePath()).replace(/\/$/, ""),
897
- ".ts-cloud",
898
- relative(p.projectPath(), p.frameworkRuntimePath()).replace(/\/$/, ""),
899
- "tmp",
900
- "temp",
901
- ".DS_Store",
902
- "*.log",
903
- ".env",
904
- ".env.local",
905
- ".env.keys",
906
- ".env.production.bak",
907
- ".env.production.plain",
908
- "*.sqlite",
909
- "*.sqlite-wal",
910
- "*.sqlite-shm"
911
- ];
912
- if (onlySite && !sites[onlySite]) {
913
- log.error(`--site '${onlySite}' is not a configured site. Available: ${Object.keys(sites).join(", ")}`);
914
- process.exit(ExitCode.FatalError);
915
- }
916
- const tarballs = new Map;
917
- for (const [siteName, site] of Object.entries(sites)) {
918
- if (!site)
919
- continue;
920
- if (onlySite && siteName !== onlySite)
921
- continue;
922
- const kind = resolveSiteKind(site);
923
- if (kind === "bucket")
924
- continue;
925
- if (kind === "redirect")
926
- continue;
927
- if (kind === "server-static" && site.build) {
928
- log.info(`Building static site '${siteName}': ${site.build}`);
929
- execSync(site.build, { stdio: verbose ? "inherit" : "pipe" });
930
- }
931
- const root = site.root || ".", tarballPath = join(tmpdir(), `${slug}-${siteName}-${sha}.tar.gz`), siteExcludes = Array.isArray(site.exclude) ? site.exclude.filter((entry) => typeof entry === "string" && entry.length > 0) : [], excludeArgs = [...tarExcludes, ...siteExcludes].flatMap((pattern) => [`--exclude='${pattern}'`, `--exclude='*/${pattern}'`]);
932
- if (siteExcludes.length > 0)
933
- log.info(`Excluding server-owned paths: ${siteExcludes.join(", ")}`);
934
- log.info(`Packaging ${root} \u2192 ${tarballPath}...`);
935
- execSync(`tar czf "${tarballPath}" ${excludeArgs.join(" ")} -C "${root}" .`, { stdio: verbose ? "inherit" : "pipe", env: { ...process.env, COPYFILE_DISABLE: "1" } });
936
- const sizeMb = Math.max(1, Math.round(statSync(tarballPath).size / 1048576));
937
- log.info(`Release tarball: ~${sizeMb} MB`);
938
- tarballs.set(siteName, tarballPath);
939
- }
940
- if (docker)
941
- await buildContainerImageWithPantry({ slug, sites, verbose });
942
- const resolvedDeployEnv = await resolveDeployEnvValues(environment, tsCloudConfig), sitesWithResolvedEnv = applyPersistentStatePaths(mergeSiteDeployEnv(sites, resolvedDeployEnv), slug);
943
- for (const [envKey, envValue] of Object.entries(resolvedDeployEnv))
944
- if (process.env[envKey] === void 0)
945
- process.env[envKey] = envValue;
946
- const githubDeployments = await startGithubDeployments({ sites, onlySite, environment, resolveSiteKind });
947
- log.info(onlySite ? `Shipping site '${onlySite}' to the server...` : "Shipping release to the server...");
948
- const deployConfig = onlySite ? { ...tsCloudConfig, sites: { [onlySite]: sitesWithResolvedEnv[onlySite] } } : { ...tsCloudConfig, sites: sitesWithResolvedEnv }, ok = await deployAllComputeSites({
949
- config: deployConfig,
950
- managementDashboard: !onlySite,
951
- rpxConfig: { ...tsCloudConfig, sites: sitesWithResolvedEnv },
952
- environment,
953
- driver,
954
- sha,
955
- runtime: tsCloudConfig.infrastructure?.compute?.runtime || "bun",
956
- tarballForSite: (siteName) => {
957
- const path = tarballs.get(siteName);
958
- if (!path)
959
- throw Error(`Missing tarball for site '${siteName}'`);
960
- return path;
961
- },
962
- logger: {
963
- info: (m) => log.info(m),
964
- warn: (m) => log.warn(m),
965
- error: (m) => log.error(m),
966
- step: (m) => log.info(m),
967
- success: (m) => log.success(m)
968
- }
969
- });
970
- let publishedDns = [];
971
- if (ok)
972
- publishedDns = await reconcileHetznerDns(onlySite ? { [onlySite]: sites[onlySite] } : sites, ip, log, ipv6);
973
- if (ok && publishedDns.length > 0) {
974
- log.info(`Issuing TLS for ${publishedDns.length} newly published record(s)...`);
975
- try {
976
- const { execSync } = await import("node:child_process"), unit = `rpx-cert-renew-${tsCloudConfig.project?.slug || "app"}.service`, certSshArgs = ["-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20", `root@${ip}`], out = execSync(`ssh ${certSshArgs.map((a) => `'${a}'`).join(" ")} bash -s`, {
977
- input: `systemctl start ${unit} 2>&1 || true
17
+ `.trim(),encoded=Buffer.from(probe).toString("base64"),{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),line=(await sshExecOrThrow(ip,`/usr/local/bin/bun -e "eval(Buffer.from('${encoded}','base64').toString())"`,{user:"root",connectTimeoutSec:10})).trim().split(`
18
+ `).at(-1)||"{}",livePorts=JSON.parse(line),result=reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts);for(const siteName of result.preserved)log.info(`Partial deploy: preserving the live management dashboard service '${siteName}' on port ${livePorts[siteName]}`);for(const siteName of result.removed)log.info(`Partial deploy: omitting management dashboard route '${siteName}' because no active service owns it`)}export function resolvePersistedAttachTargetBox(tsCloudConfig,environment,cwd=process.cwd()){const owner=tsCloudConfig.cloud?.attachTo;if(!owner)return null;const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,statePath=join(cwd,"storage","cloud","state",`${stackName}.json`);if(!existsSync(statePath))return null;try{const state=JSON.parse(readFileSync(statePath,"utf8"));if(state.stackName!==stackName||typeof state.serverId!=="number"||typeof state.publicIp!=="string"||!state.publicIp.trim())return null;return{serverId:state.serverId,serverName:typeof state.serverName==="string"&&state.serverName?state.serverName:`${owner}-${environment}-app`,publicIp:state.publicIp,publicIpv6:typeof state.publicIpv6==="string"?state.publicIpv6:void 0}}catch{return null}}export function scrubLoopbackSitePortsForFirewall(tsCloudConfig){const sites=tsCloudConfig?.sites;if(!sites)return tsCloudConfig;const loopbackHosts=new Set(["127.0.0.1","::1","localhost"]),scrubbed={};for(const[siteName,site]of Object.entries(sites)){const host=String(site?.env?.HOST??"").toLowerCase();if(site&&typeof site.port==="number"&&!site.domain&&loopbackHosts.has(host)){const rest={...site};delete rest.port;scrubbed[siteName]=rest}else scrubbed[siteName]=site}return{...tsCloudConfig,sites:scrubbed}}function githubDeploymentsEnabled(){return process.env.GITHUB_ACTIONS!=="true"&&process.env.TS_CLOUD_GITHUB_DEPLOYMENTS!=="0"}async function ghCliAvailable(){try{const{execSync}=await import("node:child_process");execSync("gh --version",{stdio:"ignore"});return!0}catch{return!1}}async function resolveSiteGithubSource(root){try{const{execSync}=await import("node:child_process"),run=(cmd)=>execSync(cmd,{cwd:root,stdio:["ignore","pipe","ignore"]}).toString().trim(),match=run("git config --get remote.origin.url").match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);if(!match?.[1])return null;return{repo:match[1],ref:run("git rev-parse HEAD")}}catch{return null}}async function setGithubDeploymentStatus(record,state){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({state,environment:record.environment,...record.environmentUrl?{environment_url:record.environmentUrl}:{},description:state==="success"?"Deployed":state==="failure"?"Deploy failed":"Deploying"});execSync(`gh api -X POST repos/${record.repo}/deployments/${record.id}/statuses --input -`,{input:body,stdio:["pipe","ignore","ignore"]})}catch(err){log.warn(`GitHub deployment status (${state}) skipped for ${record.repo}: ${getErrorMessage(err)}`)}}async function startGithubDeployment(source,environment,environmentUrl){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({ref:source.ref,environment,description:`buddy deploy (${environment})`,auto_merge:!1,required_contexts:[],production_environment:environment==="production"}),out=execSync(`gh api -X POST repos/${source.repo}/deployments --input - --jq '.id'`,{input:body,stdio:["pipe","pipe","ignore"]}).toString().trim(),id=Number(out);if(!out||!Number.isInteger(id)||id<=0)return null;const record={repo:source.repo,id,environment,environmentUrl};await setGithubDeploymentStatus(record,"in_progress");return record}catch(err){log.warn(`GitHub deployment record skipped for ${source.repo}: ${getErrorMessage(err)}`);return null}}async function startGithubDeployments(args){const{sites,onlySite,environment,resolveSiteKind}=args,records=[];if(!githubDeploymentsEnabled()||!await ghCliAvailable())return records;const seen=new Set;for(const[siteName,site]of Object.entries(sites)){if(!site||onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket"||kind==="redirect")continue;const source=await resolveSiteGithubSource(site.root||".");if(!source)continue;const key=`${source.repo}@${source.ref}`;if(seen.has(key))continue;seen.add(key);const record=await startGithubDeployment(source,environment,site.domain?`https://${site.domain}`:void 0);if(record){records.push(record);log.info(`GitHub deployment ${record.repo}#${record.id} \u2192 ${environment}`)}}return records}async function runHetznerDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite,persistedAttachBox}=args,startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy \u2192 Hetzner Cloud");console.log("");log.info(`Project: ${tsCloudConfig.project?.slug}`);log.info(`Environment: ${environment}`);log.info(`Location: ${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"}`);log.info(`Size: ${tsCloudConfig.infrastructure?.compute?.size||"small"}`);try{if(shouldInjectManagementDashboard(tsCloudConfig)&&typeof ensureManagementDashboard==="function")ensureManagementDashboard(tsCloudConfig,{cwd:process.cwd(),logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m)}});else if(tsCloudConfig.cloud?.attachTo)log.info(`Management dashboard: using the '${tsCloudConfig.cloud.attachTo}' server owner's dashboard`)}catch(err){log.warn(`Management dashboard injection skipped: ${getErrorMessage(err)}`)}const driver=createCloudDriver({config:tsCloudConfig,provider:"hetzner"});if(!driver.provisionComputeInfrastructure){log.error("Hetzner driver does not support compute provisioning (update @stacksjs/ts-cloud).");process.exit(ExitCode.FatalError)}const attachTo=tsCloudConfig.cloud?.attachTo;let ip,ipv6;if(attachTo){const box=persistedAttachBox??await resolveAttachTargetBox(attachTo,environment);if(!box?.publicIp){log.error(`Attach target '${attachTo}' has no reachable box for '${environment}'. Is '${attachTo}-${environment}-app' provisioned (by its owner)?`);process.exit(ExitCode.FatalError)}ip=box.publicIp;ipv6=box.publicIpv6;log.info(`Attaching to '${attachTo}' box '${box.serverName}' (${ip}) \u2014 skipping provisioning`);const compute=(tsCloudConfig.infrastructure??={}).compute??={};compute.webServer="rpx";compute.proxy={onDemandTls:!0,...compute.proxy??{},engine:"rpx"};const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,stateDir=join(process.cwd(),"storage","cloud","state");mkdirSync(stateDir,{recursive:!0});writeFileSync(join(stateDir,`${stackName}.json`),`${JSON.stringify({stackName,serverId:box.serverId,serverName:box.serverName,publicIp:ip,sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"},null,2)}
19
+ `)}else{log.info("Provisioning Hetzner compute infrastructure...");const outputs=await driver.provisionComputeInfrastructure({config:scrubLoopbackSitePortsForFirewall(tsCloudConfig),environment});ip=outputs.appPublicIp;ipv6=outputs.appPublicIpv6;log.success("Hetzner compute infrastructure ready");if(outputs.appInstanceId)log.info(`Server ID: ${outputs.appInstanceId}`)}if(ip)log.info(`Server IP: ${ip}`);if(!ip){log.error("Provisioned server has no public IP \u2014 cannot deploy over SSH.");process.exit(ExitCode.FatalError)}await waitForRemoteReady(ip);if(onlySite&&!attachTo)await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,ip);const{execSync}=await import("node:child_process"),{tmpdir}=await import("node:os"),sites=applyEnvironmentToSites(tsCloudConfig.sites||{},environment,tsCloudConfig),slug=tsCloudConfig.project?.slug||"app";let sha;try{sha=execSync("git rev-parse --short HEAD",{stdio:["ignore","pipe","ignore"]}).toString().trim()}catch{sha=Date.now().toString(36)}const tarExcludes=["node_modules","pantry",".git",".github",".cache","bin","dist",relative(p.projectPath(),p.stxPath()).replace(/\/$/,""),".stx",relative(p.projectPath(),p.cloudStatePath()).replace(/\/$/,""),".ts-cloud",relative(p.projectPath(),p.frameworkRuntimePath()).replace(/\/$/,""),"tmp","temp",".DS_Store","*.log",".env",".env.local",".env.keys",".env.production.bak",".env.production.plain","*.sqlite","*.sqlite-wal","*.sqlite-shm"];if(onlySite&&!sites[onlySite]){log.error(`--site '${onlySite}' is not a configured site. Available: ${Object.keys(sites).join(", ")}`);process.exit(ExitCode.FatalError)}const tarballs=new Map;for(const[siteName,site]of Object.entries(sites)){if(!site)continue;if(onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket")continue;if(kind==="redirect")continue;if(kind==="server-static"&&site.build){log.info(`Building static site '${siteName}': ${site.build}`);execSync(site.build,{stdio:verbose?"inherit":"pipe"})}const root=site.root||".",tarballPath=join(tmpdir(),`${slug}-${siteName}-${sha}.tar.gz`),siteExcludes=Array.isArray(site.exclude)?site.exclude.filter((entry)=>typeof entry==="string"&&entry.length>0):[],excludeArgs=[...tarExcludes,...siteExcludes].flatMap((pattern)=>[`--exclude='${pattern}'`,`--exclude='*/${pattern}'`]);if(siteExcludes.length>0)log.info(`Excluding server-owned paths: ${siteExcludes.join(", ")}`);log.info(`Packaging ${root} \u2192 ${tarballPath}...`);execSync(`tar czf "${tarballPath}" ${excludeArgs.join(" ")} -C "${root}" .`,{stdio:verbose?"inherit":"pipe",env:{...process.env,COPYFILE_DISABLE:"1"}});const sizeMb=Math.max(1,Math.round(statSync(tarballPath).size/1048576));log.info(`Release tarball: ~${sizeMb} MB`);tarballs.set(siteName,tarballPath)}if(docker)await buildContainerImageWithPantry({slug,sites,verbose});const resolvedDeployEnv=await resolveDeployEnvValues(environment,tsCloudConfig),sitesWithResolvedEnv=applyPersistentStatePaths(mergeSiteDeployEnv(sites,resolvedDeployEnv),slug);for(const[envKey,envValue]of Object.entries(resolvedDeployEnv))if(process.env[envKey]===void 0)process.env[envKey]=envValue;const githubDeployments=await startGithubDeployments({sites,onlySite,environment,resolveSiteKind});log.info(onlySite?`Shipping site '${onlySite}' to the server...`:"Shipping release to the server...");const deployConfig=onlySite?{...tsCloudConfig,sites:{[onlySite]:sitesWithResolvedEnv[onlySite]}}:{...tsCloudConfig,sites:sitesWithResolvedEnv},ok=await deployAllComputeSites({config:deployConfig,managementDashboard:!onlySite,rpxConfig:{...tsCloudConfig,sites:sitesWithResolvedEnv},environment,driver,sha,runtime:tsCloudConfig.infrastructure?.compute?.runtime||"bun",tarballForSite:(siteName)=>{const path=tarballs.get(siteName);if(!path)throw Error(`Missing tarball for site '${siteName}'`);return path},logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m),error:(m)=>log.error(m),step:(m)=>log.info(m),success:(m)=>log.success(m)}});let publishedDns=[];if(ok)publishedDns=await reconcileHetznerDns(onlySite?{[onlySite]:sites[onlySite]}:sites,ip,log,ipv6);if(ok&&publishedDns.length>0){log.info(`Issuing TLS for ${publishedDns.length} newly published record(s)...`);try{const{execSync}=await import("node:child_process"),unit=`rpx-cert-renew-${tsCloudConfig.project?.slug||"app"}.service`,certSshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],out=execSync(`ssh ${certSshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`systemctl start ${unit} 2>&1 || true
978
20
  systemctl is-active ${unit} >/dev/null 2>&1 && echo TLSUNIT:running || echo TLSUNIT:done
979
- journalctl -u ${unit} -n 20 --no-pager 2>/dev/null | grep -E 'Certificate written|Skipping|error|Error' | tail -5 || true`,
980
- encoding: "utf8",
981
- stdio: ["pipe", "pipe", "pipe"]
982
- });
983
- for (const line of out.split(`
984
- `))
985
- if (/Certificate written|Skipping/.test(line))
986
- log.info(` ${line.replace(/^.*?\]:\s*/, "").trim()}`);
987
- log.success("TLS issued and gateway reloaded for the new record(s)");
988
- } catch (err) {
989
- log.warn(`TLS issuance after DNS failed: ${err?.message || err}`);
990
- log.warn(` Until it succeeds, ${publishedDns.join(", ")} serves a fallback certificate.`);
991
- }
992
- }
993
- if (ok)
994
- await reconcileConfigDns(onlySite ? { [onlySite]: sites[onlySite] } : sites, log);
995
- if (ok) {
996
- const mailOwner = mailServerOwnerFromConfig(emailConfig);
997
- let mailIp = ip;
998
- if (mailOwner) {
999
- const mailBox = await resolveAttachTargetBox(mailOwner, environment);
1000
- if (mailBox?.publicIp) {
1001
- mailIp = mailBox.publicIp;
1002
- log.info(`Mail: reconciling on '${mailOwner}' box '${mailBox.serverName}' (${mailIp})`);
1003
- } else {
1004
- mailIp = void 0;
1005
- log.warn(`Mail: attach target '${mailOwner}' has no reachable '${environment}' box; application deploy remains live`);
1006
- }
1007
- }
1008
- const mailRes = mailIp ? await provisionMailTenant(mailIp, log) : null;
1009
- if (mailOwner && mailIp && mailIp !== ip)
1010
- await cleanupDetachedMailHealth(ip, log);
1011
- if (mailRes)
1012
- await reconcileMailDns(mailRes, mailIp, log);
1013
- }
1014
- for (const record of githubDeployments)
1015
- await setGithubDeploymentStatus(record, ok ? "success" : "failure");
1016
- console.log("");
1017
- if (ok) {
1018
- await outro(`Deployed to Hetzner. Your site is live at http://${ip}:3000`, { startTime, useSeconds: !0 });
1019
- log.info(`Coming-soon page: http://${ip}:3000 (bypass with ?secret=\u2026)`);
1020
- } else {
1021
- await outro("Hetzner deploy reported a failure \u2014 see the per-instance output above.", { startTime, useSeconds: !0 });
1022
- process.exit(ExitCode.FatalError);
1023
- }
1024
- }
1025
- function resolveMailboxes(mailboxes, domain) {
1026
- if (!Array.isArray(mailboxes))
1027
- return [];
1028
- const out = [];
1029
- for (const entry of mailboxes) {
1030
- let raw, explicitPw;
1031
- if (typeof entry === "string")
1032
- raw = entry;
1033
- else if (entry && typeof entry === "object") {
1034
- raw = entry.email ?? entry.username;
1035
- explicitPw = entry.password;
1036
- }
1037
- if (!raw || typeof raw !== "string")
1038
- continue;
1039
- const localPart = (raw.includes("@") ? raw.split("@")[0] ?? "" : raw).trim();
1040
- if (!localPart)
1041
- continue;
1042
- const address = `${localPart}@${domain}`, envPw = explicitPw || process.env[`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`];
1043
- if (!envPw)
1044
- continue;
1045
- out.push({ address, localPart: localPart.toUpperCase(), password: envPw, generated: !1 });
1046
- }
1047
- return out;
1048
- }
1049
- export function hasExplicitEmailConfig(projectRoot = p.projectPath()) {
1050
- return existsSync(join(projectRoot, "config", "email.ts"));
1051
- }
1052
- export function mailServerOwnerFromConfig(config) {
1053
- const owner = config?.server?.attachTo;
1054
- return typeof owner === "string" && owner.trim() ? owner.trim() : void 0;
1055
- }
1056
- async function cleanupDetachedMailHealth(ip, logger) {
1057
- const { execSync } = await import("node:child_process"), sshArgs = ["-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20", `root@${ip}`], script = `set -e
21
+ journalctl -u ${unit} -n 20 --no-pager 2>/dev/null | grep -E 'Certificate written|Skipping|error|Error' | tail -5 || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]});for(const line of out.split(`
22
+ `))if(/Certificate written|Skipping/.test(line))log.info(` ${line.replace(/^.*?\]:\s*/,"").trim()}`);log.success("TLS issued and gateway reloaded for the new record(s)")}catch(err){log.warn(`TLS issuance after DNS failed: ${err?.message||err}`);log.warn(` Until it succeeds, ${publishedDns.join(", ")} serves a fallback certificate.`)}}if(ok)await reconcileConfigDns(onlySite?{[onlySite]:sites[onlySite]}:sites,log);if(ok){const mailOwner=mailServerOwnerFromConfig(emailConfig);let mailIp=ip;if(mailOwner){const mailBox=await resolveAttachTargetBox(mailOwner,environment);if(mailBox?.publicIp){mailIp=mailBox.publicIp;log.info(`Mail: reconciling on '${mailOwner}' box '${mailBox.serverName}' (${mailIp})`)}else{mailIp=void 0;log.warn(`Mail: attach target '${mailOwner}' has no reachable '${environment}' box; application deploy remains live`)}}const mailRes=mailIp?await provisionMailTenant(mailIp,log):null;if(mailOwner&&mailIp&&mailIp!==ip)await cleanupDetachedMailHealth(ip,log);if(mailRes)await reconcileMailDns(mailRes,mailIp,log)}for(const record of githubDeployments)await setGithubDeploymentStatus(record,ok?"success":"failure");console.log("");if(ok){await outro(`Deployed to Hetzner. Your site is live at http://${ip}:3000`,{startTime,useSeconds:!0});log.info(`Coming-soon page: http://${ip}:3000 (bypass with ?secret=\u2026)`)}else{await outro("Hetzner deploy reported a failure \u2014 see the per-instance output above.",{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}}function resolveMailboxes(mailboxes,domain){if(!Array.isArray(mailboxes))return[];const out=[];for(const entry of mailboxes){let raw,explicitPw;if(typeof entry==="string")raw=entry;else if(entry&&typeof entry==="object"){raw=entry.email??entry.username;explicitPw=entry.password}if(!raw||typeof raw!=="string")continue;const localPart=(raw.includes("@")?raw.split("@")[0]??"":raw).trim();if(!localPart)continue;const address=`${localPart}@${domain}`,envPw=explicitPw||process.env[`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`];if(!envPw)continue;out.push({address,localPart:localPart.toUpperCase(),password:envPw,generated:!1})}return out}export function hasExplicitEmailConfig(projectRoot=p.projectPath()){return existsSync(join(projectRoot,"config","email.ts"))}export function mailServerOwnerFromConfig(config){const owner=config?.server?.attachTo;return typeof owner==="string"&&owner.trim()?owner.trim():void 0}async function cleanupDetachedMailHealth(ip,logger){const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],script=`set -e
1058
23
  if systemctl list-unit-files --type=service --no-legend | awk '{print $1}' | grep -qx mail.service; then
1059
24
  exit 0
1060
25
  fi
@@ -1065,67 +30,10 @@ rm -f /etc/systemd/system/mail-health.service /etc/systemd/system/mail-health.ti
1065
30
  rm -f /usr/local/sbin/mail-health-check /etc/systemd/system/mail.service.d/reliability.conf
1066
31
  rmdir /etc/systemd/system/mail.service.d 2>/dev/null || true
1067
32
  systemctl daemon-reload
1068
- systemctl reset-failed`;
1069
- try {
1070
- execSync(`ssh ${sshArgs.map((a) => `'${a}'`).join(" ")} bash -s`, {
1071
- input: script,
1072
- encoding: "utf8",
1073
- stdio: ["pipe", "pipe", "pipe"]
1074
- });
1075
- logger.success("Mail: removed the detached health timer from the application box");
1076
- } catch (err) {
1077
- logger.warn(`Mail: could not remove the detached health timer: ${getErrorMessage(err)}`);
1078
- }
1079
- }
1080
- async function resolveAttachTargetBox(owner, environment) {
1081
- const token = process.env.HCLOUD_TOKEN;
1082
- if (!token)
1083
- return null;
1084
- const pick = (servers) => servers.find((s) => s?.status !== "off" && s?.public_net?.ipv4?.ip) || servers[0], req = async (qs) => {
1085
- try {
1086
- const res = await fetch(`https://api.hetzner.cloud/v1/servers?${qs}`, {
1087
- headers: { Authorization: `Bearer ${token}` }
1088
- });
1089
- if (!res.ok)
1090
- return [];
1091
- return (await res.json()).servers || [];
1092
- } catch {
1093
- return [];
1094
- }
1095
- }, byLabel = await req(`label_selector=${encodeURIComponent(`ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app`)}`), chosen = pick(byLabel) || pick(await req(`name=${encodeURIComponent(`${owner}-${environment}-app`)}`));
1096
- if (!chosen)
1097
- return null;
1098
- const { hetznerBoxIpv6 } = await import("@stacksjs/ts-cloud");
1099
- return {
1100
- serverId: chosen.id,
1101
- serverName: chosen.name,
1102
- publicIp: chosen.public_net?.ipv4?.ip,
1103
- publicIpv6: hetznerBoxIpv6?.(chosen.public_net?.ipv6?.ip)
1104
- };
1105
- }
1106
- export async function provisionMailTenant(ip, logger) {
1107
- if (!hasExplicitEmailConfig())
1108
- return null;
1109
- const cfg = emailConfig || {};
1110
- if (cfg.server?.enabled === !1)
1111
- return null;
1112
- const domain = cfg.domain || (typeof cfg.from?.address === "string" && cfg.from.address.includes("@") ? cfg.from.address.split("@")[1] : void 0), declaredForwards = cfg.forwards && typeof cfg.forwards === "object" ? cfg.forwards : {}, forwards = { ...declaredForwards }, declaredBoxes = new Set(domain ? resolveMailboxes(cfg.mailboxes, domain).map((b) => b.address) : []);
1113
- for (const [key, targets] of Object.entries(declaredForwards)) {
1114
- const at = key.indexOf("@");
1115
- if (at === -1 || declaredBoxes.has(key))
1116
- continue;
1117
- const localPart = key.slice(0, at);
1118
- if (key.slice(at + 1) !== domain || forwards[localPart])
1119
- continue;
1120
- forwards[localPart] = targets;
1121
- }
1122
- const hasForwards = Object.keys(forwards).length > 0, boxes = domain ? resolveMailboxes(cfg.mailboxes, domain) : [];
1123
- if (!domain && !hasForwards)
1124
- return null;
1125
- const { execSync } = await import("node:child_process"), sshArgs = ["-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20", `root@${ip}`], forwardsB64 = hasForwards ? Buffer.from(JSON.stringify(forwards)).toString("base64") : "", readme = "Auto-forwarding rules, re-read on every message (edits take effect immediately, no restart). KEY = the delivered mailbox: the FULL address for per-domain isolated mailboxes (e.g. no-reply@app.com), or a bare local-part for legacy role mailboxes. VALUE = list of destination addresses; targets on a local domain are written straight to that mailbox Maildir, external targets are relayed. Managed by buddy deploy " + "from config/email.ts (merge-based \u2014 hand edits to other keys are preserved).", readmeB64 = Buffer.from(readme).toString("base64"), boxesB64 = boxes.length ? Buffer.from(`${boxes.map((b) => `${b.address} ${b.password}`).join(`
33
+ systemctl reset-failed`;try{execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]});logger.success("Mail: removed the detached health timer from the application box")}catch(err){logger.warn(`Mail: could not remove the detached health timer: ${getErrorMessage(err)}`)}}async function resolveAttachTargetBox(owner,environment){const token=process.env.HCLOUD_TOKEN;if(!token)return null;const pick=(servers)=>servers.find((s)=>s?.status!=="off"&&s?.public_net?.ipv4?.ip)||servers[0],req=async(qs)=>{try{const res=await fetch(`https://api.hetzner.cloud/v1/servers?${qs}`,{headers:{Authorization:`Bearer ${token}`}});if(!res.ok)return[];return(await res.json()).servers||[]}catch{return[]}},byLabel=await req(`label_selector=${encodeURIComponent(`ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app`)}`),chosen=pick(byLabel)||pick(await req(`name=${encodeURIComponent(`${owner}-${environment}-app`)}`));if(!chosen)return null;const{hetznerBoxIpv6}=await import("@stacksjs/ts-cloud");return{serverId:chosen.id,serverName:chosen.name,publicIp:chosen.public_net?.ipv4?.ip,publicIpv6:hetznerBoxIpv6?.(chosen.public_net?.ipv6?.ip)}}export async function provisionMailTenant(ip,logger){if(!hasExplicitEmailConfig())return null;const cfg=emailConfig||{};if(cfg.server?.enabled===!1)return null;const domain=cfg.domain||(typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address.split("@")[1]:void 0),declaredForwards=cfg.forwards&&typeof cfg.forwards==="object"?cfg.forwards:{},forwards={...declaredForwards},declaredBoxes=new Set(domain?resolveMailboxes(cfg.mailboxes,domain).map((b)=>b.address):[]);for(const[key,targets]of Object.entries(declaredForwards)){const at=key.indexOf("@");if(at===-1||declaredBoxes.has(key))continue;const localPart=key.slice(0,at);if(key.slice(at+1)!==domain||forwards[localPart])continue;forwards[localPart]=targets}const hasForwards=Object.keys(forwards).length>0,boxes=domain?resolveMailboxes(cfg.mailboxes,domain):[];if(!domain&&!hasForwards)return null;const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],forwardsB64=hasForwards?Buffer.from(JSON.stringify(forwards)).toString("base64"):"",readme="Auto-forwarding rules, re-read on every message (edits take effect immediately, no restart). KEY = the delivered mailbox: the FULL address for per-domain isolated mailboxes (e.g. no-reply@app.com), or a bare local-part for legacy role mailboxes. VALUE = list of destination addresses; targets on a local domain are written straight to that mailbox Maildir, external targets are relayed. Managed by buddy deploy "+"from config/email.ts (merge-based \u2014 hand edits to other keys are preserved).",readmeB64=Buffer.from(readme).toString("base64"),boxesB64=boxes.length?Buffer.from(`${boxes.map((b)=>`${b.address} ${b.password}`).join(`
1126
34
  `)}
1127
- `).toString("base64") : "", script = `set -e
1128
- DOMAIN=${domain ? `'${domain}'` : "''"}
35
+ `).toString("base64"):"",script=`set -e
36
+ DOMAIN=${domain?`'${domain}'`:"''"}
1129
37
  FWD_B64='${forwardsB64}'
1130
38
  README_B64='${readmeB64}'
1131
39
  BOXES_B64='${boxesB64}'
@@ -1354,564 +262,5 @@ EOF
1354
262
  systemctl daemon-reload
1355
263
  systemctl enable --now mail-health.timer >/dev/null 2>&1
1356
264
  # 6) Restart only when the startup-read env actually changed (domain or DKIM key).
1357
- 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`;
1358
- try {
1359
- const out = execSync(`ssh ${sshArgs.map((a) => `'${a}'`).join(" ")} bash -s`, {
1360
- input: script,
1361
- encoding: "utf8",
1362
- stdio: ["pipe", "pipe", "pipe"]
1363
- }), line = (out.match(/MAILTENANT:[^\n]*/) || [])[0] || "MAILTENANT:done", mailHostFromOut = (out.match(/MAILHOST:([^\n]*)/) || [])[1]?.trim(), mailHost = mailHostFromOut || `mail.${domain}`, dkimPubB64 = (out.match(/DKIMPUB:([^\n]*)/) || [])[1]?.trim() || void 0, madeAddrs = new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : [])), migratedAddrs = new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : [])), created = boxes.filter((b) => madeAddrs.has(b.address)).map((b) => ({ address: b.address, password: b.password }));
1364
- logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:", "")})`);
1365
- const failed = [...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : []);
1366
- if (failed.length)
1367
- logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);
1368
- const forwardError = (out.match(/FWDERR:([^\n]*)/) || [])[1]?.trim();
1369
- if (forwardError)
1370
- logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);
1371
- const certHost = (out.match(/CERTHOST:([^\n]*)/) || [])[1]?.trim();
1372
- if (certHost)
1373
- logger.success(`Mail: ${certHost} added to the mail certificate \u2014 clients can use it as the server name`);
1374
- const certError = (out.match(/CERTFAIL:([^\n]*)/) || [])[1]?.trim();
1375
- if (certError)
1376
- logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut || "the shared mail host"}): ${certError}`);
1377
- const seen = new Set([...madeAddrs, ...migratedAddrs, ...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : [])]), unaccounted = boxes.filter((b) => !seen.has(b.address)).map((b) => b.address);
1378
- if (unaccounted.length)
1379
- logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);
1380
- if (created.length) {
1381
- logger.info(`Mail: created ${created.length} mailbox(es) \u2014 credentials below (save them; shown once):`);
1382
- for (const b of created)
1383
- logger.info(` ${b.address} ${b.password}`);
1384
- }
1385
- if (migratedAddrs.size)
1386
- logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);
1387
- return domain ? { domain, mailHost, dkimPubB64, created } : null;
1388
- } catch (err) {
1389
- logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);
1390
- return null;
1391
- }
1392
- }
1393
- export async function reconcileMailDns(res, ip, logger) {
1394
- const { domain, dkimPubB64 } = res;
1395
- let { mailHost } = res;
1396
- try {
1397
- const dns = await import("node:dns"), own = `mail.${domain}`;
1398
- if (own !== mailHost && (await dns.promises.resolve4(own)).includes(ip))
1399
- mailHost = own;
1400
- } catch {}
1401
- 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;
1402
- if (!apiKey || !secretKey) {
1403
- logger.warn(`Mail DNS: no Porkbun credentials \u2014 add these records for ${domain} by hand:`);
1404
- logger.info(` MX @ 10 ${mailHost}`);
1405
- logger.info(` TXT @ ${spf}`);
1406
- if (dkim)
1407
- logger.info(` TXT mail._domainkey ${dkim}`);
1408
- logger.info(` TXT _dmarc ${dmarc}`);
1409
- return;
1410
- }
1411
- const auth = { apikey: apiKey, secretapikey: secretKey }, call = async (path, body) => {
1412
- return (await fetch(`https://api.porkbun.com/api/json/v3/${path}`, {
1413
- method: "POST",
1414
- headers: { "Content-Type": "application/json" },
1415
- body: JSON.stringify({ ...auth, ...body })
1416
- })).json().catch(() => ({}));
1417
- }, ownership = await call(`dns/retrieve/${domain}`, {});
1418
- if (ownership?.status !== "SUCCESS") {
1419
- logger.warn(`Mail DNS skipped: ${domain} is not administered by these Porkbun credentials (${ownership?.message || "domain not in this account"}).`);
1420
- logger.info(`Set \`domain\` in config/email.ts to a zone you own, or add these records for ${domain} by hand:`);
1421
- logger.info(` MX @ 10 ${mailHost}`);
1422
- logger.info(` TXT @ ${spf}`);
1423
- if (dkim)
1424
- logger.info(` TXT mail._domainkey ${dkim}`);
1425
- logger.info(` TXT _dmarc ${dmarc}`);
1426
- return;
1427
- }
1428
- const upsert = async (type, name, content, extra = {}) => {
1429
- const sub = name === "@" ? "" : `/${name}`;
1430
- await call(`dns/deleteByNameType/${domain}/${type}${sub}`, {});
1431
- const created = await call(`dns/create/${domain}`, { name: name === "@" ? "" : name, type, content, ttl: "600", ...extra });
1432
- if (created?.status !== "SUCCESS")
1433
- throw Error(`${type} ${name}: ${created?.message || "unknown Porkbun error"}`);
1434
- };
1435
- try {
1436
- await upsert("MX", "@", mailHost, { prio: "10" });
1437
- await upsert("TXT", "@", spf);
1438
- if (dkim)
1439
- await upsert("TXT", "mail._domainkey", dkim);
1440
- await upsert("TXT", "_dmarc", dmarc);
1441
- await upsert("A", "mail", ip);
1442
- logger.success(`Mail DNS published for ${domain} (MX\u2192${mailHost}, SPF, DKIM, DMARC, mail.${domain}\u2192${ip})`);
1443
- } catch (err) {
1444
- logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`);
1445
- }
1446
- }
1447
- export function configDnsDomains(sites) {
1448
- const domains = new Set;
1449
- for (const site of Object.values(sites))
1450
- if (!site?.redirect && site?.domain && typeof site.domain === "string")
1451
- domains.add(site.domain.replace(/^www\./, ""));
1452
- return [...domains];
1453
- }
1454
- export function dnsProviderNameFromNameservers(nameservers) {
1455
- const normalized = nameservers.map((name) => name.toLowerCase().replace(/\.$/, ""));
1456
- if (normalized.some((name) => name.endsWith(".porkbun.com")))
1457
- return "porkbun";
1458
- if (normalized.some((name) => name.endsWith(".ns.cloudflare.com")))
1459
- return "cloudflare";
1460
- if (normalized.some((name) => /(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))
1461
- return "route53";
1462
- if (normalized.some((name) => name.endsWith(".domaincontrol.com")))
1463
- return "godaddy";
1464
- return null;
1465
- }
1466
- async function reconcileConfigDns(sites, logger) {
1467
- const projectDnsConfig = await loadProjectDnsConfig(dnsConfig);
1468
- if (["a", "aaaa", "cname", "mx", "txt"].reduce((total, key) => total + (Array.isArray(projectDnsConfig?.[key]) ? projectDnsConfig[key].length : 0), 0) === 0)
1469
- return;
1470
- for (const domain of configDnsDomains(sites))
1471
- try {
1472
- const result = await syncDnsConfig(domain, projectDnsConfig);
1473
- if (!result.provider)
1474
- continue;
1475
- if (result.created || result.failed)
1476
- logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed ? `, ${result.failed} failed` : ""}`);
1477
- } catch (err) {
1478
- logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error ? err.message : String(err)}`);
1479
- }
1480
- }
1481
- async function reconcileHetznerDns(sites, ip, logger, ipv6) {
1482
- const published = [], domains = new Set;
1483
- for (const site of Object.values(sites))
1484
- if (site?.domain && typeof site.domain === "string")
1485
- domains.add(site.domain.replace(/^www\./, ""));
1486
- if (domains.size === 0)
1487
- return published;
1488
- const providerConfigs = [];
1489
- if (process.env.PORKBUN_API_KEY && process.env.PORKBUN_SECRET_KEY)
1490
- providerConfigs.push({ provider: "porkbun", apiKey: process.env.PORKBUN_API_KEY, secretKey: process.env.PORKBUN_SECRET_KEY });
1491
- if (process.env.CLOUDFLARE_API_TOKEN)
1492
- providerConfigs.push({ provider: "cloudflare", apiToken: process.env.CLOUDFLARE_API_TOKEN });
1493
- if (process.env.GODADDY_API_KEY && process.env.GODADDY_API_SECRET)
1494
- providerConfigs.push({ provider: "godaddy", apiKey: process.env.GODADDY_API_KEY, apiSecret: process.env.GODADDY_API_SECRET, environment: process.env.GODADDY_ENVIRONMENT });
1495
- if (process.env.AWS_ACCESS_KEY_ID || process.env.AWS_PROFILE)
1496
- providerConfigs.push({ provider: "route53" });
1497
- if (providerConfigs.length === 0) {
1498
- logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");
1499
- for (const d of domains)
1500
- logger.info(` Point manually: A ${d} \u2192 ${ip} and A www.${d} \u2192 ${ip}`);
1501
- return published;
1502
- }
1503
- const {
1504
- createDnsProvider,
1505
- detectDnsProvider,
1506
- reconcileAddressRecords
1507
- } = await import("@stacksjs/ts-cloud");
1508
- logger.info("Reconciling DNS records...");
1509
- const resolveA = async (fqdn) => {
1510
- try {
1511
- const { resolve4 } = await import("node:dns/promises");
1512
- return await resolve4(fqdn);
1513
- } catch {
1514
- return [];
1515
- }
1516
- }, resolveAuthoritativeNameservers = async (domain) => {
1517
- try {
1518
- const { resolveNs } = await import("node:dns/promises");
1519
- return await resolveNs(domain);
1520
- } catch {
1521
- return [];
1522
- }
1523
- };
1524
- for (const domain of domains)
1525
- try {
1526
- let provider = await detectDnsProvider(domain, providerConfigs).catch((err) => {
1527
- logger.warn(` DNS: ignoring a configured provider for ${domain} - its credentials were rejected (${err?.message || err})`);
1528
- return;
1529
- });
1530
- if (!provider) {
1531
- const providerName = dnsProviderNameFromNameservers(await resolveAuthoritativeNameservers(domain)), providerConfig = providerConfigs.find((config) => config.provider === providerName);
1532
- if (providerConfig)
1533
- provider = createDnsProvider(providerConfig);
1534
- }
1535
- if (!provider) {
1536
- for (const sub of ["", "www"]) {
1537
- const fqdn = sub ? `${sub}.${domain}` : domain, current = await resolveA(fqdn);
1538
- if (current.includes(ip))
1539
- logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);
1540
- else if (current.length === 0)
1541
- logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} \u2014 create it manually: A ${fqdn} \u2192 ${ip}`);
1542
- else
1543
- logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} \u2014 update it manually: A ${fqdn} \u2192 ${ip}`);
1544
- }
1545
- continue;
1546
- }
1547
- for (const sub of ["", "www"]) {
1548
- const fqdn = sub ? `${sub}.${domain}` : domain, report = await reconcileAddressRecords({ provider, zone: domain, fqdn, ipv4: ip, ipv6 });
1549
- for (const record of report.published) {
1550
- logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);
1551
- published.push(record.fqdn);
1552
- }
1553
- for (const warning of report.warnings)
1554
- logger.warn(` DNS: ${warning}`);
1555
- }
1556
- } catch (err) {
1557
- logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message || err}`);
1558
- }
1559
- return published;
1560
- }
1561
- async function buildContainerImageWithPantry(args) {
1562
- const { slug, sites, verbose } = args, { execSync } = await import("node:child_process");
1563
- let cli;
1564
- for (const candidate of ["pantry", "ts-pantry"])
1565
- try {
1566
- execSync(`command -v ${candidate}`, { stdio: "pipe" });
1567
- cli = candidate;
1568
- break;
1569
- } catch {}
1570
- if (!cli) {
1571
- log.warn("pantry CLI not found on PATH \u2014 skipping container image build. Install pantry to enable `--docker`.");
1572
- return;
1573
- }
1574
- const dockerfile = "storage/framework/Dockerfile", canPush = Boolean(process.env.PANTRY_REGISTRY_TOKEN || process.env.PANTRY_TOKEN);
1575
- for (const [siteName, site] of Object.entries(sites)) {
1576
- if (!site?.start)
1577
- continue;
1578
- const tag = `${slug}-${siteName}:latest`;
1579
- log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);
1580
- const flags = [
1581
- "build",
1582
- ".",
1583
- "-t",
1584
- tag,
1585
- "-f",
1586
- dockerfile,
1587
- "--run-mode",
1588
- "skip"
1589
- ];
1590
- if (canPush)
1591
- flags.push("--push");
1592
- execSync(`${cli} ${flags.map((f) => f.includes(" ") ? `'${f}'` : f).join(" ")}`, {
1593
- stdio: verbose ? "inherit" : "pipe",
1594
- maxBuffer: 536870912
1595
- });
1596
- log.success(`[${siteName}] image built${canPush ? " + pushed to the pantry registry" : ""}`);
1597
- }
1598
- }
1599
- export function deploy(buddy) {
1600
- const descriptions = {
1601
- deploy: "Deploy your project",
1602
- project: "Target a specific project",
1603
- production: "Deploy to production",
1604
- development: "Deploy to development",
1605
- staging: "Deploy to staging",
1606
- yes: "Confirm all prompts by default",
1607
- domain: "Specify a domain to deploy to",
1608
- verbose: "Enable verbose output"
1609
- };
1610
- buddy.command("deploy [env]", descriptions.deploy).option("--domain", descriptions.domain, { default: void 0 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--prod", descriptions.production, { default: !0 }).option("--dev", descriptions.development, { default: !1 }).option("--yes", descriptions.yes, { default: !1 }).option("--site <name>", "Deploy only this one site to the existing server (multi-tenant surgical add)", { default: void 0 }).option("--staging", descriptions.staging, { default: !1 }).option("--docker", "Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry", { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (envArg, options) => {
1611
- log.debug("Running `buddy deploy` ...", options);
1612
- if (process.argv.includes("--dry-run") || options.dryRun === !0) {
1613
- log.error("`buddy deploy --dry-run` is not supported.");
1614
- log.info("Deploy has no preview mode: provisioning, release shipping, and DNS all mutate real infrastructure.");
1615
- log.info("To see what would ship without touching the server, inspect `config/cloud.ts` sites, or run `buddy deploy --site <name>` to narrow a real deploy to one site.");
1616
- process.exit(ExitCode.FatalError);
1617
- }
1618
- await ensureDeployPrerequisites(options.verbose === !0);
1619
- const deployEnv = envArg || (options.staging ? "staging" : options.dev ? "development" : "production"), deployEnvName = deployEnv === "prod" ? "production" : deployEnv === "dev" ? "development" : deployEnv;
1620
- process.env.APP_ENV = deployEnvName;
1621
- {
1622
- const envFile = deployEnvName === "production" ? ".env.production" : `.env.${deployEnvName}`;
1623
- if (existsSync(p.projectPath(envFile)))
1624
- try {
1625
- const { loadEnv } = await import("@stacksjs/env");
1626
- loadEnv({ path: envFile, env: deployEnvName, keysFile: ".env.keys", overload: !0, quiet: !0 });
1627
- } catch (err) {
1628
- log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`);
1629
- }
1630
- }
1631
- const tsCloudConfig = await loadTsCloudConfig(deployEnvName);
1632
- if (tsCloudConfig && resolveProvider(tsCloudConfig) === "hetzner") {
1633
- await deployToHetzner(tsCloudConfig, deployEnv, options);
1634
- return;
1635
- }
1636
- if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)
1637
- delete process.env.AWS_PROFILE;
1638
- const startTime = performance.now();
1639
- console.log("");
1640
- console.log("\uD83D\uDE80 Deploy");
1641
- console.log("");
1642
- let productionUrl;
1643
- if (deployEnv === "production" || deployEnv === "prod") {
1644
- const prodEnvPath = p.projectPath(".env.production");
1645
- if (existsSync(prodEnvPath)) {
1646
- const urlMatch = readFileSync(prodEnvPath, "utf-8").match(/^APP_URL=(.+)$/m);
1647
- if (urlMatch?.[1]) {
1648
- productionUrl = urlMatch[1].trim();
1649
- log.debug("Using APP_URL from .env.production:", productionUrl);
1650
- }
1651
- }
1652
- }
1653
- const envUrl = env.APP_URL, domain = options.domain || productionUrl || envUrl || app.url;
1654
- if ((options.prod || deployEnv === "production" || deployEnv === "prod") && !options.yes)
1655
- await confirmProductionDeployment();
1656
- if (!domain) {
1657
- log.info("No domain found in your .env.production or ./config/app.ts");
1658
- log.info("Please ensure your domain is properly configured.");
1659
- log.info("For more info, check out the docs or join our Discord.");
1660
- process.exit(ExitCode.FatalError);
1661
- }
1662
- log.info(`Deploying to ${italic(domain)} (${deployEnv})`);
1663
- await checkIfAwsIsBootstrapped(options);
1664
- options.domain = await configureDomain(domain, options, startTime);
1665
- const result = await runAction(Action.Deploy, options);
1666
- if (result.isErr) {
1667
- await outro("While running the `buddy deploy`, there was an issue", { startTime, useSeconds: !0 }, result.error);
1668
- process.exit(ExitCode.FatalError);
1669
- }
1670
- await outro("Project deployed.", { startTime, useSeconds: !0 });
1671
- });
1672
- onUnknownSubcommand(buddy, "deploy");
1673
- }
1674
- async function confirmProductionDeployment() {
1675
- if (!process.stdin.isTTY) {
1676
- log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");
1677
- log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");
1678
- process.exit(ExitCode.InvalidArgument);
1679
- }
1680
- if (!await prompts.confirm({
1681
- message: "Are you sure you want to deploy to production?",
1682
- initial: !0
1683
- })) {
1684
- log.info("Aborting deployment...");
1685
- process.exit(ExitCode.InvalidArgument);
1686
- }
1687
- }
1688
- async function configureDomain(domain, options, startTime) {
1689
- log.debug("Configuring domain...", domain);
1690
- if (!domain) {
1691
- log.info("We could not identify a domain to deploy to.");
1692
- log.warn("Please set your .env or ./config/app.ts properly.");
1693
- log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");
1694
- console.log("");
1695
- log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");
1696
- console.log("");
1697
- process.exit(ExitCode.FatalError);
1698
- }
1699
- if (domain.includes("localhost")) {
1700
- log.info("You are deploying to a local environment.");
1701
- log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");
1702
- log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");
1703
- console.log("");
1704
- log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");
1705
- console.log("");
1706
- process.exit(ExitCode.FatalError);
1707
- }
1708
- if (await hasUserDomainBeenAddedToCloud(domain)) {
1709
- log.info("Domain is properly configured");
1710
- log.info("Your cloud is deploying...");
1711
- log.info(`${italic("This may take a while...")}`);
1712
- return domain;
1713
- }
1714
- console.log("");
1715
- log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);
1716
- console.log("");
1717
- log.info(italic("Let\u2019s ensure it is all connected properly."));
1718
- log.info(italic("One moment..."));
1719
- console.log("");
1720
- const result = await addDomain({
1721
- ...options,
1722
- deploy: !0,
1723
- startTime
1724
- });
1725
- if (result.isErr) {
1726
- await outro("While running the `buddy deploy`, there was an issue", { startTime, useSeconds: !0 }, result.error);
1727
- process.exit(ExitCode.FatalError);
1728
- }
1729
- await outro("Added your domain.", { startTime, useSeconds: !0 });
1730
- process.exit(ExitCode.Success);
1731
- }
1732
- async function promptAndSaveCredentials() {
1733
- const accessKeyId = await prompts.text({
1734
- message: "AWS Access Key ID:",
1735
- validate: (value) => value.length > 0 ? !0 : "Access Key ID is required"
1736
- });
1737
- if (!accessKeyId) {
1738
- log.info("Deployment cancelled");
1739
- process.exit(ExitCode.Success);
1740
- }
1741
- const secretAccessKey = await prompts.password({
1742
- message: "AWS Secret Access Key:",
1743
- validate: (value) => value.length > 0 ? !0 : "Secret Access Key is required"
1744
- });
1745
- if (!secretAccessKey) {
1746
- log.info("Deployment cancelled");
1747
- process.exit(ExitCode.Success);
1748
- }
1749
- const region = await prompts.text({
1750
- message: "AWS Region:",
1751
- initial: "us-east-1"
1752
- });
1753
- if (!region) {
1754
- log.info("Deployment cancelled");
1755
- process.exit(ExitCode.Success);
1756
- }
1757
- const { setEnv } = await import("@stacksjs/env");
1758
- await setEnv("AWS_ACCESS_KEY_ID", accessKeyId, { file: ".env.production", encrypt: !0 });
1759
- await setEnv("AWS_SECRET_ACCESS_KEY", secretAccessKey, { file: ".env.production", encrypt: !0 });
1760
- await setEnv("AWS_REGION", region || "us-east-1", { file: ".env.production" });
1761
- process.env.AWS_ACCESS_KEY_ID = accessKeyId;
1762
- process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey;
1763
- process.env.AWS_REGION = region || "us-east-1";
1764
- log.success("AWS credentials saved securely to .env.production");
1765
- console.log("");
1766
- }
1767
- function loadAwsCredentialsFromEnv() {
1768
- const environment = process.env.APP_ENV || process.env.NODE_ENV || "production", envFiles = [
1769
- p.projectPath(`.env.${environment}`),
1770
- p.projectPath(".env")
1771
- ];
1772
- for (const envPath of envFiles) {
1773
- if (!existsSync(envPath))
1774
- continue;
1775
- try {
1776
- const lines = readFileSync(envPath, "utf-8").split(`
1777
- `);
1778
- let accessKeyId, secretAccessKey, region, accountId;
1779
- for (const line of lines) {
1780
- const trimmed = line.trim();
1781
- if (trimmed.startsWith("#") || !trimmed.includes("="))
1782
- continue;
1783
- const [key, ...valueParts] = trimmed.split("="), value = valueParts.join("=").trim();
1784
- if (key === "AWS_ACCESS_KEY_ID" && value)
1785
- accessKeyId = value;
1786
- else if (key === "AWS_SECRET_ACCESS_KEY" && value)
1787
- secretAccessKey = value;
1788
- else if (key === "AWS_REGION" && value)
1789
- region = value;
1790
- else if (key === "AWS_ACCOUNT_ID" && value)
1791
- accountId = value;
1792
- }
1793
- if (accessKeyId && secretAccessKey) {
1794
- log.debug(`Found AWS credentials in ${envPath}`);
1795
- return { accessKeyId, secretAccessKey, region, accountId };
1796
- }
1797
- } catch (error) {
1798
- log.debug(`Failed to read ${envPath} file:`, error);
1799
- }
1800
- }
1801
- return {};
1802
- }
1803
- async function checkIfAwsIsBootstrapped(options) {
1804
- let handlingAlreadyExists = !1;
1805
- try {
1806
- log.info("Ensuring AWS cloud stack exists...");
1807
- let hasCredentials = process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY;
1808
- if (!hasCredentials) {
1809
- const envCredentials = loadAwsCredentialsFromEnv();
1810
- if (envCredentials.accessKeyId && envCredentials.secretAccessKey) {
1811
- process.env.AWS_ACCESS_KEY_ID = envCredentials.accessKeyId;
1812
- process.env.AWS_SECRET_ACCESS_KEY = envCredentials.secretAccessKey;
1813
- if (envCredentials.region && !process.env.AWS_REGION)
1814
- process.env.AWS_REGION = envCredentials.region;
1815
- if (envCredentials.accountId && !process.env.AWS_ACCOUNT_ID)
1816
- process.env.AWS_ACCOUNT_ID = envCredentials.accountId;
1817
- hasCredentials = !0;
1818
- const environment = process.env.APP_ENV || process.env.NODE_ENV || "production";
1819
- log.success(`Using AWS credentials from .env.${environment}`);
1820
- }
1821
- }
1822
- if (!hasCredentials) {
1823
- const fileCredentials = loadAwsCredentialsFromFile();
1824
- if (fileCredentials.accessKeyId && fileCredentials.secretAccessKey) {
1825
- process.env.AWS_ACCESS_KEY_ID = fileCredentials.accessKeyId;
1826
- process.env.AWS_SECRET_ACCESS_KEY = fileCredentials.secretAccessKey;
1827
- if (fileCredentials.region && !process.env.AWS_REGION)
1828
- process.env.AWS_REGION = fileCredentials.region;
1829
- hasCredentials = !0;
1830
- log.success("Using AWS credentials from ~/.aws/credentials");
1831
- }
1832
- }
1833
- if (!hasCredentials) {
1834
- log.info("AWS credentials not found in .env or ~/.aws/credentials.");
1835
- log.info("You can either:");
1836
- log.info(" 1. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env.production");
1837
- log.info(" 2. Add credentials to ~/.aws/credentials");
1838
- log.info(" 3. Configure them interactively below");
1839
- console.log("");
1840
- if (options?.yes) {
1841
- log.info("Skipping credential setup (--yes flag provided)");
1842
- process.exit(ExitCode.FatalError);
1843
- }
1844
- const setupCredentials = await prompts.confirm({
1845
- message: "Would you like to configure AWS credentials now?",
1846
- initial: !0
1847
- });
1848
- log.debug("setupCredentials response:", setupCredentials, typeof setupCredentials);
1849
- if (setupCredentials === void 0 || setupCredentials === !1) {
1850
- if (setupCredentials === void 0) {
1851
- console.log("");
1852
- log.info("Deployment cancelled");
1853
- process.exit(ExitCode.Success);
1854
- }
1855
- console.log("");
1856
- log.info("Skipping cloud infrastructure check");
1857
- log.info("You can configure AWS credentials later by running: buddy configure:aws");
1858
- return !0;
1859
- }
1860
- await promptAndSaveCredentials();
1861
- } else
1862
- log.success("AWS credentials found");
1863
- const appName = (process.env.APP_NAME || app.name || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-"), stackName = `${appName}-cloud`, { AWSCloudFormationClient } = await import("@stacksjs/ts-cloud"), cfnClient = new AWSCloudFormationClient(process.env.AWS_REGION || "us-east-1");
1864
- let stackExists = !1, needsEmailUpdate = !1;
1865
- try {
1866
- const stack = (await cfnClient.describeStacks({ stackName })).Stacks?.[0];
1867
- if (stack) {
1868
- stackExists = !0;
1869
- log.success("Cloud stack exists");
1870
- const { AWSCloudFormationClient } = await import("@stacksjs/ts-cloud"), resources = await new AWSCloudFormationClient(process.env.AWS_REGION || "us-east-1").listStackResources(stackName), hasEmailBucket = resources.StackResourceSummaries?.some((r) => r.LogicalResourceId === "EmailBucket"), hasOutboundLambda = resources.StackResourceSummaries?.some((r) => r.LogicalResourceId === "OutboundEmailLambda"), hasConversionLambda = resources.StackResourceSummaries?.some((r) => r.LogicalResourceId === "EmailConversionLambda"), hasNotificationTopic = resources.StackResourceSummaries?.some((r) => r.LogicalResourceId === "EmailNotificationTopic"), hasMailApiLambda = resources.StackResourceSummaries?.some((r) => r.LogicalResourceId === "MailApiLambda"), hasMailUsersTable = resources.StackResourceSummaries?.some((r) => r.LogicalResourceId === "MailUsersTable"), hasMailServerInstance = resources.StackResourceSummaries?.some((r) => r.LogicalResourceId === "MailServerInstance"), currentEmailDomain = stack.Outputs?.find((o) => o.OutputKey === "EmailDomain")?.OutputValue, configuredDomain = (emailConfig?.from?.address?.includes("@") ? emailConfig.from.address.split("@")[1] : void 0) || "stacksjs.com";
1871
- if (!hasEmailBucket && emailConfig?.server?.scan !== void 0) {
1872
- log.info("Email infrastructure not found in stack, will update...");
1873
- needsEmailUpdate = !0;
1874
- } else if (currentEmailDomain && currentEmailDomain !== configuredDomain) {
1875
- log.info(`Email domain changed: ${currentEmailDomain} -> ${configuredDomain}, will update...`);
1876
- needsEmailUpdate = !0;
1877
- } else if (hasEmailBucket && (!hasOutboundLambda || !hasConversionLambda || !hasNotificationTopic)) {
1878
- log.info("Email infrastructure incomplete, will update...");
1879
- needsEmailUpdate = !0;
1880
- } else if (hasEmailBucket && (!hasMailApiLambda || !hasMailUsersTable)) {
1881
- log.info("Mail API infrastructure missing, will update...");
1882
- needsEmailUpdate = !0;
1883
- } else if (hasEmailBucket && !hasMailServerInstance && emailConfig?.server?.enabled) {
1884
- log.info("Mail server EC2 instance missing, will update...");
1885
- needsEmailUpdate = !0;
1886
- }
1887
- const currentMode = (stack.Outputs || []).find((o) => o.OutputKey === "MailServerMode")?.OutputValue, configuredMode = emailConfig?.server?.mode || "serverless";
1888
- if (currentMode && currentMode !== configuredMode) {
1889
- log.info(`Mail server mode changed: ${currentMode} -> ${configuredMode}, will update...`);
1890
- needsEmailUpdate = !0;
1891
- }
1892
- if (hasMailServerInstance && emailConfig?.server?.enabled) {
1893
- if (process.env.FORCE_MAIL_UPDATE === "true") {
1894
- log.info("Forcing mail server update...");
1895
- needsEmailUpdate = !0;
1896
- }
1897
- }
1898
- if (!needsEmailUpdate)
1899
- return !0;
1900
- }
1901
- } catch (error) {
1902
- const caught = error && typeof error === "object" ? error : { message: String(error) };
1903
- log.debug(`Stack not found: ${getErrorMessage(error)}`);
1904
- }
1905
- if (!stackExists)
1906
- log.info("Cloud stack not found, will be created by deploy action");
1907
- return !0;
1908
- } catch (err) {
1909
- if (!handlingAlreadyExists) {
1910
- log.error("Error checking cloud infrastructure");
1911
- log.error(`Error: ${getErrorMessage(err)}`);
1912
- if (options?.verbose)
1913
- console.error(err);
1914
- }
1915
- process.exit(ExitCode.FatalError);
1916
- }
1917
- }
265
+ 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`;try{const out=execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),line=(out.match(/MAILTENANT:[^\n]*/)||[])[0]||"MAILTENANT:done",mailHostFromOut=(out.match(/MAILHOST:([^\n]*)/)||[])[1]?.trim(),mailHost=mailHostFromOut||`mail.${domain}`,dkimPubB64=(out.match(/DKIMPUB:([^\n]*)/)||[])[1]?.trim()||void 0,madeAddrs=new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),migratedAddrs=new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),created=boxes.filter((b)=>madeAddrs.has(b.address)).map((b)=>({address:b.address,password:b.password}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[]);if(failed.length)logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);const forwardError=(out.match(/FWDERR:([^\n]*)/)||[])[1]?.trim();if(forwardError)logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);const certHost=(out.match(/CERTHOST:([^\n]*)/)||[])[1]?.trim();if(certHost)logger.success(`Mail: ${certHost} added to the mail certificate \u2014 clients can use it as the server name`);const certError=(out.match(/CERTFAIL:([^\n]*)/)||[])[1]?.trim();if(certError)logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut||"the shared mail host"}): ${certError}`);const seen=new Set([...madeAddrs,...migratedAddrs,...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])]),unaccounted=boxes.filter((b)=>!seen.has(b.address)).map((b)=>b.address);if(unaccounted.length)logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);if(created.length){logger.info(`Mail: created ${created.length} mailbox(es) \u2014 credentials below (save them; shown once):`);for(const b of created)logger.info(` ${b.address} ${b.password}`)}if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);return domain?{domain,mailHost,dkimPubB64,created}:null}catch(err){logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);return null}}export async function reconcileMailDns(res,ip,logger){const{domain,dkimPubB64}=res;let{mailHost}=res;try{const dns=await import("node:dns"),own=`mail.${domain}`;if(own!==mailHost&&(await dns.promises.resolve4(own)).includes(ip))mailHost=own}catch{}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;if(!apiKey||!secretKey){logger.warn(`Mail DNS: no Porkbun credentials \u2014 add these records for ${domain} by hand:`);logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT mail._domainkey ${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);return}const auth={apikey:apiKey,secretapikey:secretKey},call=async(path,body)=>{return(await fetch(`https://api.porkbun.com/api/json/v3/${path}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({...auth,...body})})).json().catch(()=>({}))},ownership=await call(`dns/retrieve/${domain}`,{});if(ownership?.status!=="SUCCESS"){logger.warn(`Mail DNS skipped: ${domain} is not administered by these Porkbun credentials (${ownership?.message||"domain not in this account"}).`);logger.info(`Set \`domain\` in config/email.ts to a zone you own, or add these records for ${domain} by hand:`);logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT mail._domainkey ${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);return}const upsert=async(type,name,content,extra={})=>{const sub=name==="@"?"":`/${name}`;await call(`dns/deleteByNameType/${domain}/${type}${sub}`,{});const created=await call(`dns/create/${domain}`,{name:name==="@"?"":name,type,content,ttl:"600",...extra});if(created?.status!=="SUCCESS")throw Error(`${type} ${name}: ${created?.message||"unknown Porkbun error"}`)};try{await upsert("MX","@",mailHost,{prio:"10"});await upsert("TXT","@",spf);if(dkim)await upsert("TXT","mail._domainkey",dkim);await upsert("TXT","_dmarc",dmarc);await upsert("A","mail",ip);logger.success(`Mail DNS published for ${domain} (MX\u2192${mailHost}, SPF, DKIM, DMARC, mail.${domain}\u2192${ip})`)}catch(err){logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`)}}export function configDnsDomains(sites){const domains=new Set;for(const site of Object.values(sites))if(!site?.redirect&&site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));return[...domains]}export function dnsProviderNameFromNameservers(nameservers){const normalized=nameservers.map((name)=>name.toLowerCase().replace(/\.$/,""));if(normalized.some((name)=>name.endsWith(".porkbun.com")))return"porkbun";if(normalized.some((name)=>name.endsWith(".ns.cloudflare.com")))return"cloudflare";if(normalized.some((name)=>/(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))return"route53";if(normalized.some((name)=>name.endsWith(".domaincontrol.com")))return"godaddy";return null}async function reconcileConfigDns(sites,logger){const projectDnsConfig=await loadProjectDnsConfig(dnsConfig);if(["a","aaaa","cname","mx","txt"].reduce((total,key)=>total+(Array.isArray(projectDnsConfig?.[key])?projectDnsConfig[key].length:0),0)===0)return;for(const domain of configDnsDomains(sites))try{const result=await syncDnsConfig(domain,projectDnsConfig);if(!result.provider)continue;if(result.created||result.failed)logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}async function reconcileHetznerDns(sites,ip,logger,ipv6){const published=[],domains=new Set;for(const site of Object.values(sites))if(site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));if(domains.size===0)return published;const providerConfigs=[];if(process.env.PORKBUN_API_KEY&&process.env.PORKBUN_SECRET_KEY)providerConfigs.push({provider:"porkbun",apiKey:process.env.PORKBUN_API_KEY,secretKey:process.env.PORKBUN_SECRET_KEY});if(process.env.CLOUDFLARE_API_TOKEN)providerConfigs.push({provider:"cloudflare",apiToken:process.env.CLOUDFLARE_API_TOKEN});if(process.env.GODADDY_API_KEY&&process.env.GODADDY_API_SECRET)providerConfigs.push({provider:"godaddy",apiKey:process.env.GODADDY_API_KEY,apiSecret:process.env.GODADDY_API_SECRET,environment:process.env.GODADDY_ENVIRONMENT});if(process.env.AWS_ACCESS_KEY_ID||process.env.AWS_PROFILE)providerConfigs.push({provider:"route53"});if(providerConfigs.length===0){logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const d of domains)logger.info(` Point manually: A ${d} \u2192 ${ip} and A www.${d} \u2192 ${ip}`);return published}const{createDnsProvider,detectDnsProvider,reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}},resolveAuthoritativeNameservers=async(domain)=>{try{const{resolveNs}=await import("node:dns/promises");return await resolveNs(domain)}catch{return[]}};for(const domain of domains)try{let provider=await detectDnsProvider(domain,providerConfigs).catch((err)=>{logger.warn(` DNS: ignoring a configured provider for ${domain} - its credentials were rejected (${err?.message||err})`);return});if(!provider){const providerName=dnsProviderNameFromNameservers(await resolveAuthoritativeNameservers(domain)),providerConfig=providerConfigs.find((config)=>config.provider===providerName);if(providerConfig)provider=createDnsProvider(providerConfig)}if(!provider){for(const sub of["","www"]){const fqdn=sub?`${sub}.${domain}`:domain,current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} \u2014 create it manually: A ${fqdn} \u2192 ${ip}`);else logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} \u2014 update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const sub of["","www"]){const fqdn=sub?`${sub}.${domain}`:domain,report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH \u2014 skipping container image build. Install pantry to enable `--docker`.");return}const dockerfile="storage/framework/Dockerfile",canPush=Boolean(process.env.PANTRY_REGISTRY_TOKEN||process.env.PANTRY_TOKEN);for(const[siteName,site]of Object.entries(sites)){if(!site?.start)continue;const tag=`${slug}-${siteName}:latest`;log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);const flags=["build",".","-t",tag,"-f",dockerfile,"--run-mode","skip"];if(canPush)flags.push("--push");execSync(`${cli} ${flags.map((f)=>f.includes(" ")?`'${f}'`:f).join(" ")}`,{stdio:verbose?"inherit":"pipe",maxBuffer:536870912});log.success(`[${siteName}] image built${canPush?" + pushed to the pantry registry":""}`)}}export function deploy(buddy){const descriptions={deploy:"Deploy your project",project:"Target a specific project",production:"Deploy to production",development:"Deploy to development",staging:"Deploy to staging",yes:"Confirm all prompts by default",domain:"Specify a domain to deploy to",verbose:"Enable verbose output"};buddy.command("deploy [env]",descriptions.deploy).option("--domain",descriptions.domain,{default:void 0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--prod",descriptions.production,{default:!0}).option("--dev",descriptions.development,{default:!1}).option("--yes",descriptions.yes,{default:!1}).option("--site <name>","Deploy only this one site to the existing server (multi-tenant surgical add)",{default:void 0}).option("--staging",descriptions.staging,{default:!1}).option("--docker","Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(envArg,options)=>{log.debug("Running `buddy deploy` ...",options);if(process.argv.includes("--dry-run")||options.dryRun===!0){log.error("`buddy deploy --dry-run` is not supported.");log.info("Deploy has no preview mode: provisioning, release shipping, and DNS all mutate real infrastructure.");log.info("To see what would ship without touching the server, inspect `config/cloud.ts` sites, or run `buddy deploy --site <name>` to narrow a real deploy to one site.");process.exit(ExitCode.FatalError)}await ensureDeployPrerequisites(options.verbose===!0);const deployEnv=envArg||(options.staging?"staging":options.dev?"development":"production"),deployEnvName=deployEnv==="prod"?"production":deployEnv==="dev"?"development":deployEnv;process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&resolveProvider(tsCloudConfig)==="hetzner"){await deployToHetzner(tsCloudConfig,deployEnv,options);return}if(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY)delete process.env.AWS_PROFILE;const startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy");console.log("");let productionUrl;if(deployEnv==="production"||deployEnv==="prod"){const prodEnvPath=p.projectPath(".env.production");if(existsSync(prodEnvPath)){const urlMatch=readFileSync(prodEnvPath,"utf-8").match(/^APP_URL=(.+)$/m);if(urlMatch?.[1]){productionUrl=urlMatch[1].trim();log.debug("Using APP_URL from .env.production:",productionUrl)}}}const envUrl=env.APP_URL,domain=options.domain||productionUrl||envUrl||app.url;if((options.prod||deployEnv==="production"||deployEnv==="prod")&&!options.yes)await confirmProductionDeployment();if(!domain){log.info("No domain found in your .env.production or ./config/app.ts");log.info("Please ensure your domain is properly configured.");log.info("For more info, check out the docs or join our Discord.");process.exit(ExitCode.FatalError)}log.info(`Deploying to ${italic(domain)} (${deployEnv})`);await checkIfAwsIsBootstrapped(options);options.domain=await configureDomain(domain,options,startTime);const result=await runAction(Action.Deploy,options);if(result.isErr){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Project deployed.",{startTime,useSeconds:!0})});onUnknownSubcommand(buddy,"deploy")}async function confirmProductionDeployment(){if(!process.stdin.isTTY){log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");process.exit(ExitCode.InvalidArgument)}if(!await prompts.confirm({message:"Are you sure you want to deploy to production?",initial:!0})){log.info("Aborting deployment...");process.exit(ExitCode.InvalidArgument)}}async function configureDomain(domain,options,startTime){log.debug("Configuring domain...",domain);if(!domain){log.info("We could not identify a domain to deploy to.");log.warn("Please set your .env or ./config/app.ts properly.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(domain.includes("localhost")){log.info("You are deploying to a local environment.");log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(await hasUserDomainBeenAddedToCloud(domain)){log.info("Domain is properly configured");log.info("Your cloud is deploying...");log.info(`${italic("This may take a while...")}`);return domain}console.log("");log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);console.log("");log.info(italic("Let\u2019s ensure it is all connected properly."));log.info(italic("One moment..."));console.log("");const result=await addDomain({...options,deploy:!0,startTime});if(result.isErr){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Added your domain.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}async function promptAndSaveCredentials(){const accessKeyId=await prompts.text({message:"AWS Access Key ID:",validate:(value)=>value.length>0?!0:"Access Key ID is required"});if(!accessKeyId){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const secretAccessKey=await prompts.password({message:"AWS Secret Access Key:",validate:(value)=>value.length>0?!0:"Secret Access Key is required"});if(!secretAccessKey){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const region=await prompts.text({message:"AWS Region:",initial:"us-east-1"});if(!region){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const{setEnv}=await import("@stacksjs/env");await setEnv("AWS_ACCESS_KEY_ID",accessKeyId,{file:".env.production",encrypt:!0});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production",encrypt:!0});await setEnv("AWS_REGION",region||"us-east-1",{file:".env.production"});process.env.AWS_ACCESS_KEY_ID=accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=secretAccessKey;process.env.AWS_REGION=region||"us-east-1";log.success("AWS credentials saved securely to .env.production");console.log("")}function loadAwsCredentialsFromEnv(){const environment=process.env.APP_ENV||process.env.NODE_ENV||"production",envFiles=[p.projectPath(`.env.${environment}`),p.projectPath(".env")];for(const envPath of envFiles){if(!existsSync(envPath))continue;try{const lines=readFileSync(envPath,"utf-8").split(`
266
+ `);let accessKeyId,secretAccessKey,region,accountId;for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").trim();if(key==="AWS_ACCESS_KEY_ID"&&value)accessKeyId=value;else if(key==="AWS_SECRET_ACCESS_KEY"&&value)secretAccessKey=value;else if(key==="AWS_REGION"&&value)region=value;else if(key==="AWS_ACCOUNT_ID"&&value)accountId=value}if(accessKeyId&&secretAccessKey){log.debug(`Found AWS credentials in ${envPath}`);return{accessKeyId,secretAccessKey,region,accountId}}}catch(error){log.debug(`Failed to read ${envPath} file:`,error)}}return{}}async function checkIfAwsIsBootstrapped(options){let handlingAlreadyExists=!1;try{log.info("Ensuring AWS cloud stack exists...");let hasCredentials=process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY;if(!hasCredentials){const envCredentials=loadAwsCredentialsFromEnv();if(envCredentials.accessKeyId&&envCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=envCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=envCredentials.secretAccessKey;if(envCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=envCredentials.region;if(envCredentials.accountId&&!process.env.AWS_ACCOUNT_ID)process.env.AWS_ACCOUNT_ID=envCredentials.accountId;hasCredentials=!0;const environment=process.env.APP_ENV||process.env.NODE_ENV||"production";log.success(`Using AWS credentials from .env.${environment}`)}}if(!hasCredentials){const fileCredentials=loadAwsCredentialsFromFile();if(fileCredentials.accessKeyId&&fileCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=fileCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=fileCredentials.secretAccessKey;if(fileCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=fileCredentials.region;hasCredentials=!0;log.success("Using AWS credentials from ~/.aws/credentials")}}if(!hasCredentials){log.info("AWS credentials not found in .env or ~/.aws/credentials.");log.info("You can either:");log.info(" 1. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env.production");log.info(" 2. Add credentials to ~/.aws/credentials");log.info(" 3. Configure them interactively below");console.log("");if(options?.yes){log.info("Skipping credential setup (--yes flag provided)");process.exit(ExitCode.FatalError)}const setupCredentials=await prompts.confirm({message:"Would you like to configure AWS credentials now?",initial:!0});log.debug("setupCredentials response:",setupCredentials,typeof setupCredentials);if(setupCredentials===void 0||setupCredentials===!1){if(setupCredentials===void 0){console.log("");log.info("Deployment cancelled");process.exit(ExitCode.Success)}console.log("");log.info("Skipping cloud infrastructure check");log.info("You can configure AWS credentials later by running: buddy configure:aws");return!0}await promptAndSaveCredentials()}else log.success("AWS credentials found");const appName=(process.env.APP_NAME||app.name||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),stackName=`${appName}-cloud`,{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfnClient=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1");let stackExists=!1,needsEmailUpdate=!1;try{const stack=(await cfnClient.describeStacks({stackName})).Stacks?.[0];if(stack){stackExists=!0;log.success("Cloud stack exists");const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),resources=await new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1").listStackResources(stackName),hasEmailBucket=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailBucket"),hasOutboundLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="OutboundEmailLambda"),hasConversionLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailConversionLambda"),hasNotificationTopic=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailNotificationTopic"),hasMailApiLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailApiLambda"),hasMailUsersTable=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailUsersTable"),hasMailServerInstance=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailServerInstance"),currentEmailDomain=stack.Outputs?.find((o)=>o.OutputKey==="EmailDomain")?.OutputValue,configuredDomain=(emailConfig?.from?.address?.includes("@")?emailConfig.from.address.split("@")[1]:void 0)||"stacksjs.com";if(!hasEmailBucket&&emailConfig?.server?.scan!==void 0){log.info("Email infrastructure not found in stack, will update...");needsEmailUpdate=!0}else if(currentEmailDomain&&currentEmailDomain!==configuredDomain){log.info(`Email domain changed: ${currentEmailDomain} -> ${configuredDomain}, will update...`);needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasOutboundLambda||!hasConversionLambda||!hasNotificationTopic)){log.info("Email infrastructure incomplete, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasMailApiLambda||!hasMailUsersTable)){log.info("Mail API infrastructure missing, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&!hasMailServerInstance&&emailConfig?.server?.enabled){log.info("Mail server EC2 instance missing, will update...");needsEmailUpdate=!0}const currentMode=(stack.Outputs||[]).find((o)=>o.OutputKey==="MailServerMode")?.OutputValue,configuredMode=emailConfig?.server?.mode||"serverless";if(currentMode&&currentMode!==configuredMode){log.info(`Mail server mode changed: ${currentMode} -> ${configuredMode}, will update...`);needsEmailUpdate=!0}if(hasMailServerInstance&&emailConfig?.server?.enabled){if(process.env.FORCE_MAIL_UPDATE==="true"){log.info("Forcing mail server update...");needsEmailUpdate=!0}}if(!needsEmailUpdate)return!0}}catch(error){const caught=error&&typeof error==="object"?error:{message:String(error)};log.debug(`Stack not found: ${getErrorMessage(error)}`)}if(!stackExists)log.info("Cloud stack not found, will be created by deploy action");return!0}catch(err){if(!handlingAlreadyExists){log.error("Error checking cloud infrastructure");log.error(`Error: ${getErrorMessage(err)}`);if(options?.verbose)console.error(err)}process.exit(ExitCode.FatalError)}}