@stacksjs/actions 0.70.87 → 0.70.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action.js +78 -2
- package/dist/actions.js +2 -2
- package/dist/add.js +20 -2
- package/dist/ai/request-model-access.js +10 -2
- package/dist/auth/client.js +44 -3
- package/dist/auth/prune.js +35 -2
- package/dist/auth/setup.js +152 -36
- package/dist/auth/token.js +10 -2
- package/dist/blog-admin.d.ts +55 -0
- package/dist/blog-admin.js +156 -0
- package/dist/blog.d.ts +11 -0
- package/dist/blog.js +306 -56
- package/dist/build/cli.js +6 -2
- package/dist/build/component-libs.js +7 -2
- package/dist/build/core.js +55 -2
- package/dist/build/desktop.js +5 -2
- package/dist/build/docs.js +5 -2
- package/dist/build/frontend-static.js +123 -8
- package/dist/build/server.js +6 -2
- package/dist/build/stacks.js +8 -2
- package/dist/build/views.js +11 -2
- package/dist/build.js +64 -2
- package/dist/bump.js +162 -7
- package/dist/cdn/invalidate-cache.js +0 -1
- package/dist/changelog.js +27 -3
- package/dist/check/ports.js +16 -2
- package/dist/clean.js +4 -2
- package/dist/commit.js +12 -2
- package/dist/copy-types.js +10 -3
- package/dist/database/seed.js +26 -2
- package/dist/deploy/create-receipt-rule.js +36 -2
- package/dist/deploy/index.js +1318 -44
- package/dist/dev/api.js +94 -6
- package/dist/dev/components.js +7 -2
- package/dist/dev/dashboard-globals.js +89 -3
- package/dist/dev/dashboard-utils.js +380 -2
- package/dist/dev/dashboard.js +435 -8
- package/dist/dev/desktop.js +5 -2
- package/dist/dev/docs.js +24 -2
- package/dist/dev/index.js +29 -2
- package/dist/dev/migrate-watcher.js +74 -2
- package/dist/dev/system-tray.js +5 -2
- package/dist/dev/views.js +147 -2
- package/dist/discover-packages.js +32 -2
- package/dist/domains/add.js +61 -2
- package/dist/domains/purchase.js +66 -2
- package/dist/domains/remove.js +24 -2
- package/dist/examples.js +34 -2
- package/dist/find-projects.js +16 -2
- package/dist/fresh.js +17 -2
- package/dist/generate/action-types.js +9 -4
- package/dist/generate/component-meta.js +9 -3
- package/dist/generate/custom-cli-file.js +20 -3
- package/dist/generate/env-files.js +46 -6
- package/dist/generate/ide-helpers.js +8 -3
- package/dist/generate/index.js +158 -2
- package/dist/generate/lib-entries.js +16 -3
- package/dist/generate/pantry-file.js +46 -2
- package/dist/generate/symlink-core.js +2 -2
- package/dist/generate/types.js +3 -2
- package/dist/generate/vscode-custom-data.js +9 -3
- package/dist/generate/web-types.js +0 -1
- package/dist/helpers/component-meta.js +295 -2
- package/dist/helpers/index.js +1 -2
- package/dist/helpers/lib-entries.js +73 -2
- package/dist/helpers/package-json.js +45 -14
- package/dist/helpers/utils.js +105 -2
- package/dist/helpers/vscode-custom-data.js +30 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.js +50 -2
- package/dist/key-generate.js +24 -2
- package/dist/lint/fix.js +4 -3
- package/dist/lint/index.js +4 -3
- package/dist/lint/lint.js +34 -2
- package/dist/make-command.js +64 -14
- package/dist/make-job.js +41 -16
- package/dist/make-policy.js +74 -26
- package/dist/make-resource.js +50 -21
- package/dist/make.js +328 -2
- package/dist/migrate/database.js +16 -2
- package/dist/migrate/fresh.js +31 -2
- package/dist/orm/base.js +0 -2
- package/dist/orm/test.js +0 -1
- package/dist/prepublish.js +16 -2
- package/dist/queue/clear.js +42 -2
- package/dist/queue/dlq-purge.js +28 -2
- package/dist/queue/dlq-retry.js +35 -2
- package/dist/queue/dlq.js +55 -5
- package/dist/queue/failed.js +69 -5
- package/dist/queue/flush.js +49 -3
- package/dist/queue/inspect.js +154 -8
- package/dist/queue/list.js +69 -5
- package/dist/queue/monitor.js +94 -6
- package/dist/queue/pause.js +28 -2
- package/dist/queue/quarantine.js +42 -3
- package/dist/queue/resume.js +27 -2
- package/dist/queue/retry.js +41 -2
- package/dist/queue/schedule-list.js +33 -5
- package/dist/queue/schedule.js +12 -2
- package/dist/queue/status.js +68 -5
- package/dist/queue/table.js +10 -2
- package/dist/queue/unquarantine.js +27 -2
- package/dist/queue/work.js +57 -2
- package/dist/release.js +21 -3
- package/dist/route/list.js +78 -3
- package/dist/run-action.js +1 -2
- package/dist/saas/setup.js +9 -2
- package/dist/scaffold-crud.js +164 -40
- package/dist/schedule/run.js +7 -2
- package/dist/search/flush.js +10 -2
- package/dist/search/import.js +10 -2
- package/dist/search/settings-list.js +10 -2
- package/dist/search/settings.js +9 -2
- package/dist/serve/api.js +35 -2
- package/dist/setup/index.js +1 -2
- package/dist/setup/ssl.js +271 -2
- package/dist/stacks.js +265 -2
- package/dist/templates.js +301 -2
- package/dist/test/feature.js +11 -2
- package/dist/test/index.js +11 -2
- package/dist/test/ui.js +4 -2
- package/dist/test/unit.js +11 -2
- package/dist/typecheck.js +4 -2
- package/dist/types.js +16 -2
- package/dist/upgrade/binary.js +29 -2
- package/dist/upgrade/bun.js +0 -1
- package/dist/upgrade/dependencies.js +0 -2
- package/dist/upgrade/framework-utils.js +193 -2
- package/dist/upgrade/framework.js +310 -7
- package/dist/upgrade/index.js +17 -2
- package/dist/upgrade/packages.js +99 -12
- package/dist/upgrade/shell.js +40 -4
- package/dist/upgrade.js +38 -2
- package/package.json +16 -16
- package/dist/chunk-0c8sd1t6.js +0 -2
- package/dist/chunk-0g2nzhx9.js +0 -240
- package/dist/chunk-0gadhvn7.js +0 -3
- package/dist/chunk-0h27aa4r.js +0 -93
- package/dist/chunk-3peh8kqs.js +0 -851
- package/dist/chunk-62gdfav8.js +0 -3
- package/dist/chunk-6bk53y02.js +0 -3
- package/dist/chunk-78c5n3s2.js +0 -3
- package/dist/chunk-c74qv38e.js +0 -17
- package/dist/chunk-cdk2r2jd.js +0 -1
- package/dist/chunk-dbz9dp2h.js +0 -3
- package/dist/chunk-e142dheq.js +0 -3
- package/dist/chunk-ec4gky40.js +0 -3
- package/dist/chunk-h3d81wrg.js +0 -3
- package/dist/chunk-m3q0x4h0.js +0 -688
- package/dist/chunk-mqgbyhph.js +0 -5
- package/dist/chunk-n24m8prb.js +0 -3
- package/dist/chunk-n5eqwhck.js +0 -7
- package/dist/chunk-nsvnqv8y.js +0 -5
- package/dist/chunk-nty6mmx4.js +0 -283
- package/dist/chunk-s735m9mg.js +0 -1
- package/dist/chunk-tpb8jgsk.js +0 -7
- package/dist/chunk-tsbk5vne.js +0 -3
- package/dist/chunk-xg9xscqz.js +0 -1
- package/dist/chunk-y6e448he.js +0 -7
package/dist/deploy/index.js
CHANGED
|
@@ -1,15 +1,613 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { runCommand, spinner } from "@stacksjs/cli";
|
|
4
|
+
import { config } from "@stacksjs/config";
|
|
5
|
+
import { path as p } from "@stacksjs/path";
|
|
6
|
+
import { storage } from "@stacksjs/storage";
|
|
7
|
+
import { ExitCode } from "@stacksjs/types";
|
|
8
|
+
const log = {
|
|
9
|
+
debug: (...args) => {
|
|
10
|
+
if (process.argv.includes("--verbose") || process.argv.includes("-v"))
|
|
11
|
+
console.log("\uD83D\uDD0D", ...args);
|
|
12
|
+
},
|
|
13
|
+
success: (...args) => console.log("\u2713", ...args),
|
|
14
|
+
error: (...args) => console.error("\u2717", ...args)
|
|
15
|
+
}, 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"], isVerbose = process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
16
|
+
async function collectMatchingFiles(root, names, maxDepth = 8) {
|
|
17
|
+
const { existsSync, readdirSync, statSync } = await import("node:fs"), { join, basename } = await import("node:path"), nameSet = new Set(names), matches = [];
|
|
18
|
+
if (!existsSync(root))
|
|
19
|
+
return matches;
|
|
20
|
+
function walk(dir, depth) {
|
|
21
|
+
if (depth > maxDepth)
|
|
22
|
+
return;
|
|
23
|
+
for (const entry of readdirSync(dir)) {
|
|
24
|
+
if (entry === ".git" || entry === "node_modules")
|
|
25
|
+
continue;
|
|
26
|
+
const fullPath = join(dir, entry), stat = statSync(fullPath);
|
|
27
|
+
if (stat.isDirectory())
|
|
28
|
+
walk(fullPath, depth + 1);
|
|
29
|
+
else if (stat.isFile() && nameSet.has(basename(fullPath)))
|
|
30
|
+
matches.push(fullPath);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
walk(root, 0);
|
|
34
|
+
return matches;
|
|
35
|
+
}
|
|
36
|
+
async function isElfBinary(filePath) {
|
|
37
|
+
const { readFileSync } = await import("node:fs"), header = readFileSync(filePath).slice(0, 4);
|
|
38
|
+
return header[0] === 127 && header[1] === 69 && header[2] === 76 && header[3] === 70;
|
|
39
|
+
}
|
|
40
|
+
async function resolvePantryInstallCommand() {
|
|
41
|
+
const { existsSync } = await import("node:fs"), localPantryCli = `${process.env.HOME}/Code/Tools/pantry/packages/ts-pantry/bin/cli.ts`;
|
|
42
|
+
if (existsSync(localPantryCli))
|
|
43
|
+
return { command: "bun", args: [localPantryCli] };
|
|
44
|
+
const projectPantry = p.projectPath("pantry/.bin/pantry");
|
|
45
|
+
if (existsSync(projectPantry))
|
|
46
|
+
return { command: projectPantry, args: [] };
|
|
47
|
+
const globalPantry = `${process.env.HOME}/.local/share/pantry/global/bin/pantry`;
|
|
48
|
+
if (existsSync(globalPantry))
|
|
49
|
+
return { command: globalPantry, args: [] };
|
|
50
|
+
return { command: "pantry", args: [] };
|
|
51
|
+
}
|
|
52
|
+
async function installMailBinaryWithPantry() {
|
|
53
|
+
const { execFileSync } = await import("node:child_process"), installDir = p.projectPath("pantry"), pantry = await resolvePantryInstallCommand();
|
|
54
|
+
execFileSync(pantry.command, [
|
|
55
|
+
...pantry.args,
|
|
56
|
+
"install",
|
|
57
|
+
MAIL_PACKAGE_SPEC,
|
|
58
|
+
"--install-dir",
|
|
59
|
+
installDir,
|
|
60
|
+
"--platform",
|
|
61
|
+
MAIL_TARGET_PLATFORM,
|
|
62
|
+
"--quiet"
|
|
63
|
+
], {
|
|
64
|
+
cwd: p.projectPath(),
|
|
65
|
+
stdio: isVerbose ? "inherit" : "pipe",
|
|
66
|
+
env: process.env
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async function findPantryMailBinary() {
|
|
70
|
+
const { existsSync } = await import("node:fs"), { join } = await import("node:path"), pantryRoots = [
|
|
71
|
+
p.projectPath("pantry"),
|
|
72
|
+
`${process.env.HOME}/.local/share/pantry`
|
|
73
|
+
], directCandidates = [
|
|
74
|
+
...MAIL_BINARY_NAMES.map((name) => p.projectPath(`pantry/.bin/${name}`)),
|
|
75
|
+
...MAIL_BINARY_NAMES.map((name) => join(`${process.env.HOME}/.local/share/pantry/global/bin`, name))
|
|
76
|
+
];
|
|
77
|
+
for (const candidate of directCandidates)
|
|
78
|
+
if (existsSync(candidate) && await isElfBinary(candidate))
|
|
79
|
+
return candidate;
|
|
80
|
+
for (const root of pantryRoots) {
|
|
81
|
+
const candidates = await collectMatchingFiles(root, MAIL_BINARY_NAMES);
|
|
82
|
+
for (const candidate of candidates)
|
|
83
|
+
if (await isElfBinary(candidate))
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const MIME_TYPES = {
|
|
89
|
+
".html": "text/html",
|
|
90
|
+
".css": "text/css",
|
|
91
|
+
".js": "application/javascript",
|
|
92
|
+
".json": "application/json",
|
|
93
|
+
".png": "image/png",
|
|
94
|
+
".jpg": "image/jpeg",
|
|
95
|
+
".jpeg": "image/jpeg",
|
|
96
|
+
".gif": "image/gif",
|
|
97
|
+
".svg": "image/svg+xml",
|
|
98
|
+
".ico": "image/x-icon",
|
|
99
|
+
".woff": "font/woff",
|
|
100
|
+
".woff2": "font/woff2",
|
|
101
|
+
".ttf": "font/ttf",
|
|
102
|
+
".eot": "application/vnd.ms-fontobject",
|
|
103
|
+
".otf": "font/otf",
|
|
104
|
+
".webp": "image/webp",
|
|
105
|
+
".mp4": "video/mp4",
|
|
106
|
+
".webm": "video/webm",
|
|
107
|
+
".mp3": "audio/mpeg",
|
|
108
|
+
".wav": "audio/wav",
|
|
109
|
+
".pdf": "application/pdf",
|
|
110
|
+
".xml": "application/xml",
|
|
111
|
+
".txt": "text/plain"
|
|
112
|
+
};
|
|
113
|
+
function getMimeType(filePath) {
|
|
114
|
+
const dotIndex = filePath.lastIndexOf("."), ext = dotIndex >= 0 ? filePath.slice(dotIndex).toLowerCase() : "";
|
|
115
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
116
|
+
}
|
|
117
|
+
async function traverseDirectory(dir, callback, prefix = "", skipPatterns = [".DS_Store"]) {
|
|
118
|
+
const { readdirSync, statSync } = await import("node:fs"), { join } = await import("node:path"), items = readdirSync(dir);
|
|
119
|
+
for (const item of items) {
|
|
120
|
+
if (skipPatterns.includes(item))
|
|
121
|
+
continue;
|
|
122
|
+
const filePath = join(dir, item), key = prefix ? `${prefix}/${item}` : item;
|
|
123
|
+
if (statSync(filePath).isDirectory())
|
|
124
|
+
await traverseDirectory(filePath, callback, key, skipPatterns);
|
|
125
|
+
else
|
|
126
|
+
await callback(filePath, key);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function withS3Retry(fn, label = "s3 operation") {
|
|
130
|
+
const maxRetries = 3;
|
|
131
|
+
for (let attempt = 0;attempt <= maxRetries; attempt++)
|
|
132
|
+
try {
|
|
133
|
+
return await fn();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (attempt >= maxRetries)
|
|
136
|
+
throw error;
|
|
137
|
+
const delay = Math.min(500 * 2 ** attempt + Math.random() * 200, 5000);
|
|
138
|
+
if (isVerbose)
|
|
139
|
+
log.debug(` Retrying ${label} (attempt ${attempt + 2}/${maxRetries + 1}) in ${Math.round(delay)}ms...`);
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
141
|
+
}
|
|
142
|
+
throw Error(`${label} failed after ${maxRetries + 1} attempts`);
|
|
143
|
+
}
|
|
144
|
+
function normalizeArray(value) {
|
|
145
|
+
if (!value)
|
|
146
|
+
return [];
|
|
147
|
+
return Array.isArray(value) ? value : [value];
|
|
148
|
+
}
|
|
149
|
+
function buildCloudFrontXmlElement(name, value, indent = "") {
|
|
150
|
+
if (value === null || value === void 0)
|
|
151
|
+
return "";
|
|
152
|
+
if (typeof value === "boolean")
|
|
153
|
+
return `${indent}<${name}>${value}</${name}>
|
|
154
|
+
`;
|
|
155
|
+
if (typeof value === "number" || typeof value === "string")
|
|
156
|
+
return `${indent}<${name}>${value}</${name}>
|
|
157
|
+
`;
|
|
158
|
+
if (Array.isArray(value))
|
|
159
|
+
return value.map((item) => buildCloudFrontXmlElement(name, item, indent)).join("");
|
|
160
|
+
if (typeof value === "object") {
|
|
161
|
+
if (name.startsWith("@_") || name === "?xml")
|
|
162
|
+
return "";
|
|
163
|
+
let children = "";
|
|
164
|
+
for (const [key, val] of Object.entries(value))
|
|
165
|
+
if (!key.startsWith("@_") && key !== "#text")
|
|
166
|
+
children += buildCloudFrontXmlElement(key, val, `${indent} `);
|
|
167
|
+
if (value["#text"] !== void 0)
|
|
168
|
+
return `${indent}<${name}>${value["#text"]}</${name}>
|
|
169
|
+
`;
|
|
170
|
+
return `${indent}<${name}>
|
|
171
|
+
${children}${indent}</${name}>
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
function buildCloudFrontDistributionConfigXml(config) {
|
|
177
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
8
178
|
<DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
|
9
|
-
${Object.entries(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
179
|
+
${Object.entries(config).filter(([key]) => !key.startsWith("@_")).map(([key, val]) => buildCloudFrontXmlElement(key, val, " ")).join("")}</DistributionConfig>
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
function firewallOpenCommand(ports) {
|
|
183
|
+
const uniquePorts = [...new Set(ports)].filter((port) => Number.isFinite(port) && port > 0).sort((a, b) => a - b);
|
|
184
|
+
if (uniquePorts.length === 0)
|
|
185
|
+
return "true";
|
|
186
|
+
return `if command -v firewall-cmd >/dev/null 2>&1; then firewall-cmd --permanent ${uniquePorts.map((port) => `--add-port=${port}/tcp`).join(" ")} && firewall-cmd --reload; fi`;
|
|
187
|
+
}
|
|
188
|
+
async function ensureSecurityGroupPort(ec2, securityGroupId, port) {
|
|
189
|
+
if (!securityGroupId)
|
|
190
|
+
return;
|
|
191
|
+
if (((await ec2.describeSecurityGroups({ GroupIds: [securityGroupId] }))?.SecurityGroups?.[0]?.IpPermissions || []).some((permission) => {
|
|
192
|
+
return permission.IpProtocol === "tcp" && Number(permission.FromPort) <= port && Number(permission.ToPort) >= port && normalizeArray(permission.IpRanges).some((range) => range.CidrIp === "0.0.0.0/0");
|
|
193
|
+
}))
|
|
194
|
+
return;
|
|
195
|
+
try {
|
|
196
|
+
await ec2.authorizeSecurityGroupIngress({
|
|
197
|
+
GroupId: securityGroupId,
|
|
198
|
+
IpPermissions: [{
|
|
199
|
+
IpProtocol: "tcp",
|
|
200
|
+
FromPort: port,
|
|
201
|
+
ToPort: port,
|
|
202
|
+
IpRanges: [{ CidrIp: "0.0.0.0/0", Description: `Stacks API port ${port}` }]
|
|
203
|
+
}]
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (!String(error?.message || "").includes("InvalidPermission.Duplicate"))
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function ensureCloudFrontApiOriginPort(awsClient, distributionId, port) {
|
|
211
|
+
if (!distributionId)
|
|
212
|
+
return;
|
|
213
|
+
const getResult = await awsClient.request({
|
|
214
|
+
service: "cloudfront",
|
|
215
|
+
region: "us-east-1",
|
|
216
|
+
method: "GET",
|
|
217
|
+
path: `/2020-05-31/distribution/${distributionId}/config`,
|
|
218
|
+
returnHeaders: !0
|
|
219
|
+
}), etag = getResult.headers?.etag || getResult.headers?.ETag || "", currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig || getResult.body;
|
|
220
|
+
if (!etag || !currentConfig?.Origins?.Items)
|
|
221
|
+
return;
|
|
222
|
+
const apiOrigin = normalizeArray(currentConfig.Origins.Items.Origin).find((origin) => String(origin.Id || "").includes("-api"));
|
|
223
|
+
if (!apiOrigin?.CustomOriginConfig)
|
|
224
|
+
return;
|
|
225
|
+
if (Number(apiOrigin.CustomOriginConfig.HTTPPort) === port)
|
|
226
|
+
return;
|
|
227
|
+
apiOrigin.CustomOriginConfig.HTTPPort = port;
|
|
228
|
+
await awsClient.request({
|
|
229
|
+
service: "cloudfront",
|
|
230
|
+
region: "us-east-1",
|
|
231
|
+
method: "PUT",
|
|
232
|
+
path: `/2020-05-31/distribution/${distributionId}/config`,
|
|
233
|
+
body: buildCloudFrontDistributionConfigXml(currentConfig),
|
|
234
|
+
headers: {
|
|
235
|
+
"Content-Type": "application/xml",
|
|
236
|
+
"If-Match": etag
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const frameworkBuildSpinner = spinner("Building framework...");
|
|
241
|
+
frameworkBuildSpinner.start();
|
|
242
|
+
await runCommand("bun run build", {
|
|
243
|
+
cwd: p.frameworkPath("core"),
|
|
244
|
+
quiet: !1
|
|
245
|
+
});
|
|
246
|
+
frameworkBuildSpinner.succeed("Framework built");
|
|
247
|
+
const docsDir = p.projectPath("docs"), docsBuildDir = p.projectPath("dist/docs/.bunpress");
|
|
248
|
+
if (storage.hasFiles(docsDir)) {
|
|
249
|
+
const docsSpinner = spinner("Building documentation with BunPress...");
|
|
250
|
+
docsSpinner.start();
|
|
251
|
+
try {
|
|
252
|
+
const { rm } = await import("node:fs/promises"), localBunpressCli = `${process.env.HOME}/Code/Tools/bunpress/packages/bunpress/bin/cli.ts`, installedBunpressCli = p.projectPath("node_modules/@stacksjs/bunpress/dist/bin/cli.js"), hasLocalBunpress = await Bun.file(localBunpressCli).exists(), hasInstalledBunpress = await Bun.file(installedBunpressCli).exists(), command = hasLocalBunpress ? `bun ${localBunpressCli} build --dir ${docsDir} --outdir ${p.projectPath("dist/docs")}` : hasInstalledBunpress ? `bun ${installedBunpressCli} build --dir ${docsDir} --outdir ${p.projectPath("dist/docs")}` : "bunx @stacksjs/bunpress@0.1.5 build --dir ./docs --outdir ./dist/docs";
|
|
253
|
+
await rm(docsBuildDir, { recursive: !0, force: !0 });
|
|
254
|
+
await runCommand(command, {
|
|
255
|
+
cwd: p.projectPath(),
|
|
256
|
+
quiet: !isVerbose
|
|
257
|
+
});
|
|
258
|
+
docsSpinner.succeed(`Documentation built with BunPress${hasLocalBunpress ? " (local checkout)" : ""}`);
|
|
259
|
+
} catch (docsError) {
|
|
260
|
+
docsSpinner.stop();
|
|
261
|
+
console.log(`\u26A0 Documentation build skipped: ${docsError.message}`);
|
|
262
|
+
if (isVerbose)
|
|
263
|
+
log.debug("To build docs manually: cd ~/Code/Tools/bunpress && bun packages/bunpress/bin/cli.ts build --dir /path/to/docs --outdir /path/to/dist/docs");
|
|
264
|
+
}
|
|
265
|
+
} else if (isVerbose)
|
|
266
|
+
log.debug("No docs directory found, skipping documentation build");
|
|
267
|
+
const blogDistExists = storage.hasFiles(p.projectPath("dist/blog")), blogFontAssetsExist = [
|
|
268
|
+
"CampmateScript-Regular.woff2",
|
|
269
|
+
"SequoiaSans-Regular.woff2",
|
|
270
|
+
"Switchback-Regular.woff2"
|
|
271
|
+
].every((file) => existsSync(p.projectPath(`dist/blog/assets/fonts/nps/${file}`))), blogImageAssetsExist = [
|
|
272
|
+
"topography.svg",
|
|
273
|
+
"park-ridge.svg"
|
|
274
|
+
].every((file) => existsSync(p.projectPath(`dist/blog/assets/images/${file}`))), blogIndexPath = p.projectPath("dist/blog/index.html"), blogIndexHtml = existsSync(blogIndexPath) ? readFileSync(blogIndexPath, "utf8") : "", blogHtmlUsesCurrentFontFormat = blogIndexHtml.length > 0 && !blogIndexHtml.includes("woff2-variations"), blogHtmlUsesCurrentParkTheme = blogIndexHtml.includes("--on-primary") && blogIndexHtml.includes("--newsletter-bg"), blogHtmlUsesCurrentParkImages = blogIndexHtml.includes("topography.svg") && blogIndexHtml.includes("park-ridge.svg"), blogHtmlOmitsRetiredDecorations = !blogIndexHtml.includes("stamp-trail") && !blogIndexHtml.includes("blog-sign-tree.svg"), blogBuilderPath = p.frameworkPath("core/cms/src/build.ts"), blogConfigPath = p.projectPath("config/blog.ts"), blogHtmlIsFresh = existsSync(blogIndexPath) && [blogBuilderPath, blogConfigPath].filter((path) => existsSync(path)).every((path) => statSync(blogIndexPath).mtimeMs >= statSync(path).mtimeMs);
|
|
275
|
+
if (!blogDistExists || !blogFontAssetsExist || !blogImageAssetsExist || !blogHtmlUsesCurrentFontFormat || !blogHtmlUsesCurrentParkTheme || !blogHtmlUsesCurrentParkImages || !blogHtmlOmitsRetiredDecorations || !blogHtmlIsFresh) {
|
|
276
|
+
const blogBuildSpinner = spinner("Building blog...");
|
|
277
|
+
blogBuildSpinner.start();
|
|
278
|
+
try {
|
|
279
|
+
const blogConfig = (await import(p.projectPath("config/blog"))).default, { buildBlogSite } = await import(p.frameworkPath("core/cms/src/build"));
|
|
280
|
+
await buildBlogSite({ config: blogConfig, outDir: p.projectPath("dist/blog") });
|
|
281
|
+
blogBuildSpinner.succeed("Blog built successfully");
|
|
282
|
+
} catch (blogBuildError) {
|
|
283
|
+
blogBuildSpinner.stop();
|
|
284
|
+
console.log(`\u26A0 Blog build skipped: ${blogBuildError.message}`);
|
|
285
|
+
if (isVerbose)
|
|
286
|
+
log.debug(`Blog build error: ${blogBuildError.stack}`);
|
|
287
|
+
}
|
|
288
|
+
} else if (isVerbose)
|
|
289
|
+
log.debug("Pre-built blog found at dist/blog, skipping build");
|
|
290
|
+
if (isVerbose)
|
|
291
|
+
log.debug("Skipping views build (vite-config not configured)");
|
|
292
|
+
const earlyCloudConfig = await import(p.projectPath("config/cloud")), earlyDeploymentMode = earlyCloudConfig?.tsCloud?.mode || "server";
|
|
293
|
+
if (earlyDeploymentMode === "serverless") {
|
|
294
|
+
const serverSpinner = spinner("Building server...");
|
|
295
|
+
serverSpinner.start();
|
|
296
|
+
await runCommand("bun build.ts", {
|
|
297
|
+
cwd: p.frameworkPath("server"),
|
|
298
|
+
quiet: !isVerbose
|
|
299
|
+
});
|
|
300
|
+
serverSpinner.succeed("Server built");
|
|
301
|
+
const packageSpinner = spinner("Packaging for deployment...");
|
|
302
|
+
packageSpinner.start();
|
|
303
|
+
await runCommand("bun zip.ts", {
|
|
304
|
+
cwd: p.corePath("cloud"),
|
|
305
|
+
quiet: !isVerbose
|
|
306
|
+
});
|
|
307
|
+
packageSpinner.succeed("Package ready");
|
|
308
|
+
} else if (isVerbose)
|
|
309
|
+
log.debug("Skipping server build and packaging (server mode)");
|
|
310
|
+
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
|
|
311
|
+
const { existsSync, readFileSync } = await import("node:fs"), environment = process.env.APP_ENV || process.env.NODE_ENV || "production", envFiles = [
|
|
312
|
+
p.projectPath(`.env.${environment}`),
|
|
313
|
+
p.projectPath(".env")
|
|
314
|
+
];
|
|
315
|
+
for (const envPath of envFiles)
|
|
316
|
+
if (existsSync(envPath)) {
|
|
317
|
+
const content = readFileSync(envPath, "utf-8");
|
|
318
|
+
for (const line of content.split(`
|
|
319
|
+
`)) {
|
|
320
|
+
const trimmed = line.trim();
|
|
321
|
+
if (trimmed.startsWith("#") || !trimmed.includes("="))
|
|
322
|
+
continue;
|
|
323
|
+
const eqIndex = trimmed.indexOf("="), key = trimmed.slice(0, eqIndex).trim();
|
|
324
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
325
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))
|
|
326
|
+
value = value.slice(1, -1);
|
|
327
|
+
if (key === "AWS_ACCESS_KEY_ID" && value)
|
|
328
|
+
process.env.AWS_ACCESS_KEY_ID = value;
|
|
329
|
+
else if (key === "AWS_SECRET_ACCESS_KEY" && value)
|
|
330
|
+
process.env.AWS_SECRET_ACCESS_KEY = value;
|
|
331
|
+
else if (key === "AWS_REGION" && value && !process.env.AWS_REGION)
|
|
332
|
+
process.env.AWS_REGION = value;
|
|
333
|
+
else if (key === "AWS_ACCOUNT_ID" && value && !process.env.AWS_ACCOUNT_ID)
|
|
334
|
+
process.env.AWS_ACCOUNT_ID = value;
|
|
335
|
+
else if (key.startsWith("MAIL_PASSWORD_") && value && !process.env[key])
|
|
336
|
+
process.env[key] = value;
|
|
337
|
+
}
|
|
338
|
+
if (isVerbose)
|
|
339
|
+
log.debug(`Loaded AWS credentials from ${envPath}`);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const { deployStack, deployFrontend: _deployFrontend } = await import("../../deploy"), environment = process.env.APP_ENV || process.env.NODE_ENV || "production", region = process.env.AWS_REGION || "us-east-1", cloudConfigModule = await import(p.projectPath("config/cloud")), deploymentMode = cloudConfigModule?.tsCloud?.mode || "server", projectName = cloudConfigModule?.tsCloud?.project?.name || cloudConfigModule?.tsCloud?.project?.slug || "stacks";
|
|
345
|
+
if (deploymentMode === "serverless") {
|
|
346
|
+
console.log("");
|
|
347
|
+
console.log("Serverless deployment mode detected");
|
|
348
|
+
console.log("Building and pushing container image...");
|
|
349
|
+
console.log("");
|
|
350
|
+
const accountId = process.env.AWS_ACCOUNT_ID || await getAwsAccountId(region);
|
|
351
|
+
if (!accountId || accountId.length < 10)
|
|
352
|
+
throw Error(`Invalid AWS Account ID: "${accountId || "(empty)"}"
|
|
353
|
+
Set AWS_ACCOUNT_ID in your .env file or ensure valid AWS credentials are configured.`);
|
|
354
|
+
const ecrRepository = `${accountId}.dkr.ecr.${region}.amazonaws.com/${projectName}-${environment}-api`, imageTag = `${ecrRepository}:latest`;
|
|
355
|
+
if (isVerbose)
|
|
356
|
+
log.debug(`ECR repository: ${ecrRepository}`);
|
|
357
|
+
if (isVerbose)
|
|
358
|
+
log.debug("ECR credentials will be validated before push...");
|
|
359
|
+
const buildSpinner = spinner("Building Docker image...");
|
|
360
|
+
buildSpinner.start();
|
|
361
|
+
try {
|
|
362
|
+
const { exec } = await import("node:child_process"), { promisify } = await import("node:util"), execAsync = promisify(exec), buildCmd = `docker build --platform linux/amd64 -t ${imageTag} -f Dockerfile .`, { stdout, stderr } = await execAsync(buildCmd, {
|
|
363
|
+
cwd: p.projectPath(),
|
|
364
|
+
env: process.env,
|
|
365
|
+
maxBuffer: 10485760,
|
|
366
|
+
timeout: 900000
|
|
367
|
+
});
|
|
368
|
+
if (isVerbose && stdout)
|
|
369
|
+
log.debug(stdout);
|
|
370
|
+
if (isVerbose && stderr)
|
|
371
|
+
log.debug(stderr);
|
|
372
|
+
buildSpinner.succeed(`Docker image built: ${imageTag}`);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
buildSpinner.fail("Docker build failed");
|
|
375
|
+
if (isVerbose)
|
|
376
|
+
log.debug(`Docker build error: ${error.message}`);
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
const repoSpinner = spinner("Checking ECR repository...");
|
|
380
|
+
repoSpinner.start();
|
|
381
|
+
try {
|
|
382
|
+
const { ECRClient } = await import("@stacksjs/ts-cloud"), ecr = new ECRClient(region), repoName = `${projectName}-${environment}-api`;
|
|
383
|
+
try {
|
|
384
|
+
await ecr.describeRepositories({ repositoryNames: [repoName] });
|
|
385
|
+
repoSpinner.succeed(`ECR repository exists: ${repoName}`);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
if (err.code === "RepositoryNotFoundException" || err.message?.includes("RepositoryNotFoundException")) {
|
|
388
|
+
repoSpinner.text = `Creating ECR repository: ${repoName}...`;
|
|
389
|
+
await ecr.createRepository({
|
|
390
|
+
repositoryName: repoName,
|
|
391
|
+
imageScanningConfiguration: { scanOnPush: !0 },
|
|
392
|
+
imageTagMutability: "MUTABLE"
|
|
393
|
+
});
|
|
394
|
+
repoSpinner.succeed(`ECR repository created: ${repoName}`);
|
|
395
|
+
} else
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
398
|
+
} catch (error) {
|
|
399
|
+
repoSpinner.fail("ECR repository check failed");
|
|
400
|
+
if (isVerbose)
|
|
401
|
+
log.debug(`ECR repository error: ${error.message}`);
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
const authSpinner = spinner("Authenticating to ECR...");
|
|
405
|
+
authSpinner.start();
|
|
406
|
+
try {
|
|
407
|
+
const { ECRClient } = await import("@stacksjs/ts-cloud"), token = (await new ECRClient(region).getAuthorizationToken()).authorizationData?.[0];
|
|
408
|
+
if (!token?.authorizationToken)
|
|
409
|
+
throw Error("No authorization token received from ECR");
|
|
410
|
+
const decoded = Buffer.from(token.authorizationToken, "base64").toString("utf-8"), colonIndex = decoded.indexOf(":");
|
|
411
|
+
if (colonIndex === -1)
|
|
412
|
+
throw Error("ECR authorization token has unexpected format (missing colon separator)");
|
|
413
|
+
const password = decoded.slice(colonIndex + 1), loginResult = Bun.spawnSync([
|
|
414
|
+
"docker",
|
|
415
|
+
"login",
|
|
416
|
+
"--username",
|
|
417
|
+
"AWS",
|
|
418
|
+
"--password-stdin",
|
|
419
|
+
`${accountId}.dkr.ecr.${region}.amazonaws.com`
|
|
420
|
+
], {
|
|
421
|
+
stdin: new TextEncoder().encode(password),
|
|
422
|
+
stdout: "pipe",
|
|
423
|
+
stderr: "pipe"
|
|
424
|
+
});
|
|
425
|
+
if (loginResult.exitCode !== 0) {
|
|
426
|
+
const loginError = new TextDecoder().decode(loginResult.stderr);
|
|
427
|
+
throw Error(`Docker login failed: ${loginError}`);
|
|
428
|
+
}
|
|
429
|
+
authSpinner.succeed("Authenticated to ECR");
|
|
430
|
+
} catch (error) {
|
|
431
|
+
authSpinner.fail("ECR authentication failed");
|
|
432
|
+
if (isVerbose)
|
|
433
|
+
log.debug(`ECR auth error: ${error.message}`);
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
console.log("");
|
|
437
|
+
const pushSpinner = spinner("Pushing image to ECR...");
|
|
438
|
+
pushSpinner.start();
|
|
439
|
+
try {
|
|
440
|
+
const { exec } = await import("node:child_process"), { promisify } = await import("node:util"), execAsync = promisify(exec), pushCmd = `docker push ${imageTag}`;
|
|
441
|
+
await execAsync(pushCmd, { env: process.env });
|
|
442
|
+
pushSpinner.succeed(`Image pushed: ${imageTag}`);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
pushSpinner.fail("Docker push failed");
|
|
445
|
+
if (isVerbose)
|
|
446
|
+
log.debug(`Push error: ${error.message}`);
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
console.log("");
|
|
450
|
+
console.log("Container image ready in ECR. CloudFormation will use this image.");
|
|
451
|
+
console.log("");
|
|
452
|
+
} else {
|
|
453
|
+
console.log("");
|
|
454
|
+
console.log("Server deployment mode detected");
|
|
455
|
+
console.log("Deploying EC2 infrastructure via CloudFormation...");
|
|
456
|
+
console.log("");
|
|
457
|
+
}
|
|
458
|
+
async function getAwsAccountId(region) {
|
|
459
|
+
try {
|
|
460
|
+
const { STSClient } = await import("@stacksjs/ts-cloud"), accountId = (await new STSClient(region).getCallerIdentity()).Account || process.env.AWS_ACCOUNT_ID || "";
|
|
461
|
+
if (isVerbose)
|
|
462
|
+
log.debug(`Retrieved AWS Account ID: ${accountId || "(empty)"}`);
|
|
463
|
+
return accountId;
|
|
464
|
+
} catch (error) {
|
|
465
|
+
if (isVerbose)
|
|
466
|
+
log.debug(`Failed to get AWS Account ID: ${error.message}`);
|
|
467
|
+
return process.env.AWS_ACCOUNT_ID || "";
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const deployScript = await import(p.projectPath("cloud/deploy-script"));
|
|
472
|
+
if (typeof deployScript.beforeDeploy === "function")
|
|
473
|
+
await deployScript.beforeDeploy({ environment, region });
|
|
474
|
+
} catch (hookError) {
|
|
475
|
+
if (hookError.code === "ERR_MODULE_NOT_FOUND" || hookError.code === "MODULE_NOT_FOUND") {
|
|
476
|
+
if (isVerbose)
|
|
477
|
+
log.debug("No beforeDeploy hook found (cloud/deploy-script not found)");
|
|
478
|
+
} else {
|
|
479
|
+
console.warn(`\u26A0 beforeDeploy hook failed: ${hookError.message}`);
|
|
480
|
+
if (isVerbose)
|
|
481
|
+
log.debug(`beforeDeploy error stack: ${hookError.stack}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
await deployStack({
|
|
485
|
+
environment,
|
|
486
|
+
region,
|
|
487
|
+
waitForCompletion: !0,
|
|
488
|
+
verbose: isVerbose
|
|
489
|
+
});
|
|
490
|
+
try {
|
|
491
|
+
const deployScript = await import(p.projectPath("cloud/deploy-script"));
|
|
492
|
+
if (typeof deployScript.afterDeploy === "function") {
|
|
493
|
+
const { CloudFormationClient } = await import("@stacksjs/ts-cloud"), cf = new CloudFormationClient(region), stackName = `${projectName}-cloud`;
|
|
494
|
+
let stackOutputs = {};
|
|
495
|
+
try {
|
|
496
|
+
stackOutputs = await cf.getStackOutputs(stackName);
|
|
497
|
+
} catch (e) {
|
|
498
|
+
if (isVerbose)
|
|
499
|
+
log.debug(`Failed to get stack outputs: ${e instanceof Error ? e.message : String(e)}`);
|
|
500
|
+
}
|
|
501
|
+
await deployScript.afterDeploy({ environment, region, outputs: stackOutputs });
|
|
502
|
+
}
|
|
503
|
+
} catch (hookError) {
|
|
504
|
+
if (hookError.code === "ERR_MODULE_NOT_FOUND" || hookError.code === "MODULE_NOT_FOUND") {
|
|
505
|
+
if (isVerbose)
|
|
506
|
+
log.debug("No afterDeploy hook found (cloud/deploy-script not found)");
|
|
507
|
+
} else {
|
|
508
|
+
console.warn(`\u26A0 afterDeploy hook failed: ${hookError.message}`);
|
|
509
|
+
if (isVerbose)
|
|
510
|
+
log.debug(`afterDeploy error stack: ${hookError.stack}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const migrateSpinner = spinner("Running database migrations...");
|
|
514
|
+
migrateSpinner.start();
|
|
515
|
+
try {
|
|
516
|
+
const { generateMigrations, runDatabaseMigration } = await import("@stacksjs/database");
|
|
517
|
+
await generateMigrations();
|
|
518
|
+
await runDatabaseMigration();
|
|
519
|
+
migrateSpinner.succeed("Database migrations completed");
|
|
520
|
+
} catch (migrationError) {
|
|
521
|
+
migrateSpinner.stop();
|
|
522
|
+
console.log(`\u26A0 Database migrations skipped: ${migrationError.message}`);
|
|
523
|
+
if (isVerbose)
|
|
524
|
+
log.debug(`Migration error: ${migrationError.stack}`);
|
|
525
|
+
}
|
|
526
|
+
const frameworkTablesSpinner = spinner("Ensuring framework tables (auth, notifications, RBAC)...");
|
|
527
|
+
frameworkTablesSpinner.start();
|
|
528
|
+
try {
|
|
529
|
+
const { migrateAuthTables, migrateNotificationTables, migrateRbacTables } = await import("@stacksjs/database"), failures = [], steps = [
|
|
530
|
+
["auth", migrateAuthTables],
|
|
531
|
+
["notification", migrateNotificationTables],
|
|
532
|
+
["RBAC", migrateRbacTables]
|
|
533
|
+
];
|
|
534
|
+
for (const [name, migrateTables] of steps) {
|
|
535
|
+
const result = await migrateTables({ verbose: isVerbose });
|
|
536
|
+
if (!result.success)
|
|
537
|
+
failures.push(`${name}: ${result.error}`);
|
|
538
|
+
}
|
|
539
|
+
if (failures.length > 0) {
|
|
540
|
+
frameworkTablesSpinner.stop();
|
|
541
|
+
console.log(`\u26A0 Some framework tables could not be migrated: ${failures.join("; ")}`);
|
|
542
|
+
} else
|
|
543
|
+
frameworkTablesSpinner.succeed("Framework tables ensured (auth, notifications, RBAC)");
|
|
544
|
+
} catch (frameworkTablesError) {
|
|
545
|
+
frameworkTablesSpinner.stop();
|
|
546
|
+
console.log(`\u26A0 Framework table migrations skipped: ${frameworkTablesError.message}`);
|
|
547
|
+
if (isVerbose)
|
|
548
|
+
log.debug(`Framework table migration error: ${frameworkTablesError.stack}`);
|
|
549
|
+
}
|
|
550
|
+
if (deploymentMode === "server") {
|
|
551
|
+
const serverOutputSpinner = spinner("Retrieving server instance details...");
|
|
552
|
+
serverOutputSpinner.start();
|
|
553
|
+
try {
|
|
554
|
+
const { CloudFormationClient } = await import("@stacksjs/ts-cloud"), cf = new CloudFormationClient(region), stackName = `${projectName}-cloud`, outputs = await cf.getStackOutputs(stackName);
|
|
555
|
+
serverOutputSpinner.succeed("Server instances deployed");
|
|
556
|
+
console.log("");
|
|
557
|
+
console.log("Server instances:");
|
|
558
|
+
for (const [key, value] of Object.entries(outputs)) {
|
|
559
|
+
if (key.endsWith("PublicIp"))
|
|
560
|
+
console.log(` ${key}: ${value}`);
|
|
561
|
+
if (key.endsWith("InstanceId"))
|
|
562
|
+
console.log(` ${key}: ${value}`);
|
|
563
|
+
}
|
|
564
|
+
console.log("");
|
|
565
|
+
} catch (outputError) {
|
|
566
|
+
serverOutputSpinner.stop();
|
|
567
|
+
console.log(`\u26A0 Could not retrieve server details: ${outputError.message}`);
|
|
568
|
+
if (isVerbose)
|
|
569
|
+
log.debug(`Output retrieval error: ${outputError.stack}`);
|
|
570
|
+
}
|
|
571
|
+
const serverDeploySpinner = spinner("Deploying API server to EC2...");
|
|
572
|
+
serverDeploySpinner.start();
|
|
573
|
+
try {
|
|
574
|
+
const { S3Client, CloudFormationClient, AWSClient } = await import("@stacksjs/ts-cloud"), { readFileSync: readServerFile, existsSync: serverFileExists } = await import("node:fs"), { resolve } = await import("node:path"), s3 = new S3Client(region), cf = new CloudFormationClient(region), awsClient = new AWSClient, stackName = `${projectName}-cloud`, outputs = await cf.getStackOutputs(stackName), bucketName = outputs.FrontendBucketName, apiServerPort = Number(config.ports?.api || 3008);
|
|
575
|
+
let instanceId;
|
|
576
|
+
for (const [key, value] of Object.entries(outputs))
|
|
577
|
+
if (key.endsWith("InstanceId") && typeof value === "string") {
|
|
578
|
+
instanceId = value;
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
let securityGroupId;
|
|
582
|
+
if (instanceId) {
|
|
583
|
+
const { EC2Client } = await import("@stacksjs/ts-cloud"), ec2 = new EC2Client(region);
|
|
584
|
+
securityGroupId = (await ec2.describeInstances({
|
|
585
|
+
Filters: [{ Name: "instance-id", Values: [instanceId] }]
|
|
586
|
+
}))?.Reservations?.[0]?.Instances?.[0]?.SecurityGroups?.[0]?.GroupId;
|
|
587
|
+
await ensureSecurityGroupPort(ec2, securityGroupId, apiServerPort);
|
|
588
|
+
}
|
|
589
|
+
if (!instanceId || !bucketName) {
|
|
590
|
+
serverDeploySpinner.stop();
|
|
591
|
+
if (!instanceId)
|
|
592
|
+
console.log("\u26A0 No EC2 instance found in stack outputs, skipping server deploy");
|
|
593
|
+
if (!bucketName)
|
|
594
|
+
console.log("\u26A0 No S3 bucket found for staging server artifacts");
|
|
595
|
+
} else {
|
|
596
|
+
const publicDistributionId = outputs.publicCloudFrontDistributionId || outputs.CloudFrontDistributionId;
|
|
597
|
+
await ensureCloudFrontApiOriginPort(awsClient, publicDistributionId, apiServerPort);
|
|
598
|
+
const tsCloudDistPath = resolve(p.projectPath("node_modules/@stacksjs/ts-cloud/dist/index.js"));
|
|
599
|
+
if (serverFileExists(tsCloudDistPath)) {
|
|
600
|
+
const tsCloudDist = readServerFile(tsCloudDistPath);
|
|
601
|
+
await withS3Retry(() => s3.putObject({
|
|
602
|
+
bucket: bucketName,
|
|
603
|
+
key: "_deploy/ts-cloud-dist.js",
|
|
604
|
+
body: tsCloudDist,
|
|
605
|
+
contentType: "application/javascript"
|
|
606
|
+
}), "upload ts-cloud dist");
|
|
607
|
+
if (isVerbose)
|
|
608
|
+
log.debug(" Uploaded ts-cloud dist to S3");
|
|
609
|
+
}
|
|
610
|
+
const serverCode = `import { Database } from "bun:sqlite";
|
|
13
611
|
import { SESClient } from "./ts-cloud-dist.js";
|
|
14
612
|
|
|
15
613
|
const db = new Database("/var/www/api/stacks.db");
|
|
@@ -27,7 +625,7 @@ const CORS_HEADERS = {
|
|
|
27
625
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
28
626
|
};
|
|
29
627
|
|
|
30
|
-
const ses = new SESClient("${
|
|
628
|
+
const ses = new SESClient("${region}");
|
|
31
629
|
|
|
32
630
|
async function readBody(req) {
|
|
33
631
|
const contentType = req.headers.get("content-type") || "";
|
|
@@ -76,7 +674,7 @@ async function sendWelcomeEmail(email) {
|
|
|
76
674
|
}
|
|
77
675
|
|
|
78
676
|
const server = Bun.serve({
|
|
79
|
-
port: ${
|
|
677
|
+
port: ${apiServerPort},
|
|
80
678
|
hostname: "0.0.0.0",
|
|
81
679
|
async fetch(req) {
|
|
82
680
|
const url = new URL(req.url);
|
|
@@ -130,7 +728,21 @@ const server = Bun.serve({
|
|
|
130
728
|
});
|
|
131
729
|
|
|
132
730
|
console.log(\`Stacks API server running on port \${server.port}\`);
|
|
133
|
-
`;
|
|
731
|
+
`;
|
|
732
|
+
await withS3Retry(() => s3.putObject({
|
|
733
|
+
bucket: bucketName,
|
|
734
|
+
key: "_deploy/server.ts",
|
|
735
|
+
body: Buffer.from(serverCode),
|
|
736
|
+
contentType: "application/typescript"
|
|
737
|
+
}), "upload server.ts");
|
|
738
|
+
if (isVerbose)
|
|
739
|
+
log.debug(" Uploaded server.ts to S3");
|
|
740
|
+
const ssmCommand = [
|
|
741
|
+
"mkdir -p /var/www/api",
|
|
742
|
+
`aws s3 cp s3://${bucketName}/_deploy/ts-cloud-dist.js /var/www/api/ts-cloud-dist.js`,
|
|
743
|
+
`aws s3 cp s3://${bucketName}/_deploy/server.ts /var/www/api/server.ts`,
|
|
744
|
+
firewallOpenCommand([apiServerPort]),
|
|
745
|
+
`cat > /etc/systemd/system/stacks-api.service << 'SERVICEFILE'
|
|
134
746
|
[Unit]
|
|
135
747
|
Description=Stacks Bun API Server
|
|
136
748
|
After=network.target
|
|
@@ -147,16 +759,172 @@ Environment=NODE_ENV=production
|
|
|
147
759
|
WantedBy=multi-user.target
|
|
148
760
|
SERVICEFILE
|
|
149
761
|
systemctl daemon-reload
|
|
150
|
-
systemctl enable stacks-api`,
|
|
762
|
+
systemctl enable stacks-api`,
|
|
763
|
+
"systemctl reset-failed stacks-api || true",
|
|
764
|
+
"systemctl restart stacks-api",
|
|
765
|
+
`for i in $(seq 1 20); do curl -fsS http://127.0.0.1:${apiServerPort}/api/health && break; sleep 1; done`,
|
|
766
|
+
`curl -fsS http://127.0.0.1:${apiServerPort}/api/health`,
|
|
767
|
+
`echo "API server deployed and restarted on port ${apiServerPort}"`
|
|
768
|
+
].join(" && "), commandId = (await awsClient.request({
|
|
769
|
+
service: "ssm",
|
|
770
|
+
region,
|
|
771
|
+
method: "POST",
|
|
772
|
+
path: "/",
|
|
773
|
+
headers: {
|
|
774
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
775
|
+
"X-Amz-Target": "AmazonSSM.SendCommand"
|
|
776
|
+
},
|
|
777
|
+
body: JSON.stringify({
|
|
778
|
+
InstanceIds: [instanceId],
|
|
779
|
+
DocumentName: "AWS-RunShellScript",
|
|
780
|
+
Parameters: {
|
|
781
|
+
commands: [ssmCommand]
|
|
782
|
+
},
|
|
783
|
+
TimeoutSeconds: 120
|
|
784
|
+
})
|
|
785
|
+
}))?.Command?.CommandId;
|
|
786
|
+
if (commandId) {
|
|
787
|
+
let attempts = 0;
|
|
788
|
+
while (attempts < 30) {
|
|
789
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
790
|
+
try {
|
|
791
|
+
const invocation = await awsClient.request({
|
|
792
|
+
service: "ssm",
|
|
793
|
+
region,
|
|
794
|
+
method: "POST",
|
|
795
|
+
path: "/",
|
|
796
|
+
headers: {
|
|
797
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
798
|
+
"X-Amz-Target": "AmazonSSM.GetCommandInvocation"
|
|
799
|
+
},
|
|
800
|
+
body: JSON.stringify({
|
|
801
|
+
CommandId: commandId,
|
|
802
|
+
InstanceId: instanceId
|
|
803
|
+
})
|
|
804
|
+
}), status = invocation?.Status;
|
|
805
|
+
if (status === "Success") {
|
|
806
|
+
serverDeploySpinner.succeed("API server deployed to EC2");
|
|
807
|
+
break;
|
|
808
|
+
} else if (status === "Failed" || status === "Cancelled" || status === "TimedOut") {
|
|
809
|
+
const errOutput = invocation?.StandardErrorContent || "Unknown error";
|
|
810
|
+
serverDeploySpinner.fail(`API server deploy failed: ${errOutput}`);
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
} catch {}
|
|
814
|
+
attempts++;
|
|
815
|
+
}
|
|
816
|
+
if (attempts >= 30)
|
|
817
|
+
serverDeploySpinner.fail("API server deploy timed out waiting for SSM command");
|
|
818
|
+
} else
|
|
819
|
+
serverDeploySpinner.fail("Failed to send SSM command");
|
|
820
|
+
try {
|
|
821
|
+
await s3.deleteObject({ bucket: bucketName, key: "_deploy/ts-cloud-dist.js" });
|
|
822
|
+
await s3.deleteObject({ bucket: bucketName, key: "_deploy/server.ts" });
|
|
823
|
+
} catch {}
|
|
824
|
+
}
|
|
825
|
+
} catch (serverDeployError) {
|
|
826
|
+
serverDeploySpinner.fail(`API server deployment failed: ${serverDeployError.message}`);
|
|
827
|
+
if (isVerbose)
|
|
828
|
+
log.debug(`Server deploy error: ${serverDeployError.stack}`);
|
|
829
|
+
}
|
|
830
|
+
const emailConfig = config.email;
|
|
831
|
+
if (emailConfig?.server?.enabled) {
|
|
832
|
+
const mailServerMode = emailConfig?.server?.mode || "server", smtpSpinner = spinner(`Deploying mail server (${mailServerMode} mode) to EC2...`);
|
|
833
|
+
smtpSpinner.start();
|
|
834
|
+
try {
|
|
835
|
+
const { S3Client, AWSClient, EC2Client } = await import("@stacksjs/ts-cloud"), { readFileSync: readSmtpFile, existsSync: smtpFileExists } = await import("node:fs"), { resolve: resolvePath, join: joinPath } = await import("node:path"), { execSync } = await import("node:child_process"), s3 = new S3Client(region), awsClient = new AWSClient, ec2 = new EC2Client(region), stackName = `${projectName}-cloud`;
|
|
836
|
+
let smtpInstanceId, smtpBucketName, emailBucketName, securityGroupId;
|
|
837
|
+
try {
|
|
838
|
+
const { AWSCloudFormationClient: CfnClient } = await import("@stacksjs/ts-cloud"), cfnOutputs = await new CfnClient(region).getStackOutputs(stackName);
|
|
839
|
+
for (const [key, value] of Object.entries(cfnOutputs)) {
|
|
840
|
+
if (key.endsWith("InstanceId") && typeof value === "string")
|
|
841
|
+
smtpInstanceId = value;
|
|
842
|
+
if (key === "FrontendBucketName" && typeof value === "string")
|
|
843
|
+
smtpBucketName = value;
|
|
844
|
+
if (key === "emailBucketName" && typeof value === "string")
|
|
845
|
+
emailBucketName = value;
|
|
846
|
+
}
|
|
847
|
+
} catch {}
|
|
848
|
+
if (!emailBucketName)
|
|
849
|
+
emailBucketName = `${projectName}-production-s3-email`;
|
|
850
|
+
if (smtpInstanceId && smtpBucketName) {
|
|
851
|
+
const mailPorts = [25, 80, 110, 143, 465, 587, 993, 995];
|
|
852
|
+
try {
|
|
853
|
+
const sgs = (await ec2.describeInstances({
|
|
854
|
+
Filters: [{ Name: "instance-id", Values: [smtpInstanceId] }]
|
|
855
|
+
}))?.Reservations?.[0]?.Instances?.[0]?.SecurityGroups;
|
|
856
|
+
if (sgs?.[0]?.GroupId)
|
|
857
|
+
securityGroupId = sgs[0].GroupId;
|
|
858
|
+
} catch {
|
|
859
|
+
if (isVerbose)
|
|
860
|
+
log.debug("Could not get security group for instance");
|
|
861
|
+
}
|
|
862
|
+
if (securityGroupId)
|
|
863
|
+
for (const port of mailPorts)
|
|
864
|
+
try {
|
|
865
|
+
await ec2.authorizeSecurityGroupIngress({
|
|
866
|
+
GroupId: securityGroupId,
|
|
867
|
+
IpPermissions: [{
|
|
868
|
+
IpProtocol: "tcp",
|
|
869
|
+
FromPort: port,
|
|
870
|
+
ToPort: port,
|
|
871
|
+
IpRanges: [{ CidrIp: "0.0.0.0/0", Description: `Mail port ${port}` }]
|
|
872
|
+
}]
|
|
873
|
+
});
|
|
874
|
+
if (isVerbose)
|
|
875
|
+
log.debug(` Opened port ${port} on security group ${securityGroupId}`);
|
|
876
|
+
} catch (e) {
|
|
877
|
+
if (!e.message?.includes("already exists")) {
|
|
878
|
+
if (isVerbose)
|
|
879
|
+
log.debug(` Port ${port}: ${e.message}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const domain = emailConfig.domain || "stacksjs.com", mailSubdomain = emailConfig?.server?.subdomain || "mail";
|
|
883
|
+
if (mailServerMode === "server") {
|
|
884
|
+
if (isVerbose)
|
|
885
|
+
log.debug("Deploying Zig mail server binary...");
|
|
886
|
+
try {
|
|
887
|
+
await installMailBinaryWithPantry();
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (isVerbose)
|
|
890
|
+
log.debug(` Pantry mail install failed: ${error.message}`);
|
|
891
|
+
}
|
|
892
|
+
const linuxBinaryPath = await findPantryMailBinary();
|
|
893
|
+
if (linuxBinaryPath && smtpFileExists(linuxBinaryPath)) {
|
|
894
|
+
if (!await isElfBinary(linuxBinaryPath)) {
|
|
895
|
+
smtpSpinner.fail(`Pantry installed ${MAIL_PACKAGE_DOMAIN}, but the ${MAIL_TARGET_PLATFORM} binary is not an ELF file.`);
|
|
896
|
+
throw Error("Linux binary is not a valid ELF file");
|
|
897
|
+
}
|
|
898
|
+
const binaryContent = readSmtpFile(linuxBinaryPath);
|
|
899
|
+
await s3.putObject({
|
|
900
|
+
bucket: emailBucketName,
|
|
901
|
+
key: "mail-server/smtp-server",
|
|
902
|
+
body: binaryContent,
|
|
903
|
+
contentType: "application/octet-stream"
|
|
904
|
+
});
|
|
905
|
+
if (isVerbose)
|
|
906
|
+
log.debug(` Uploaded Zig binary (${(binaryContent.length / 1024 / 1024).toFixed(1)}MB) to S3`);
|
|
907
|
+
const zigSsmCommand = [
|
|
908
|
+
"systemctl stop stacks-mail 2>/dev/null || true",
|
|
909
|
+
"systemctl disable stacks-mail 2>/dev/null || true",
|
|
910
|
+
"systemctl stop stacks-smtp 2>/dev/null || true",
|
|
911
|
+
"systemctl disable stacks-smtp 2>/dev/null || true",
|
|
912
|
+
"systemctl stop smtp-server 2>/dev/null || true",
|
|
913
|
+
"mkdir -p /opt/smtp-server /var/lib/smtp-server /var/log/smtp-server /var/spool/mail /etc/smtp-server /var/lib/smtp-server/backups",
|
|
914
|
+
`aws s3 cp s3://${emailBucketName}/mail-server/smtp-server /opt/smtp-server/smtp-server --region ${region}`,
|
|
915
|
+
"chmod +x /opt/smtp-server/smtp-server",
|
|
916
|
+
'file /opt/smtp-server/smtp-server | grep -q ELF || { echo "Binary is not ELF"; exit 1; }',
|
|
917
|
+
firewallOpenCommand(mailPorts),
|
|
918
|
+
`cat > /etc/smtp-server/smtp-server.env << 'ENVEOF'
|
|
151
919
|
SMTP_PROFILE=production
|
|
152
920
|
SMTP_HOST=0.0.0.0
|
|
153
921
|
SMTP_PORT=25
|
|
154
|
-
SMTP_HOSTNAME=${
|
|
922
|
+
SMTP_HOSTNAME=${mailSubdomain}.${domain}
|
|
155
923
|
SMTP_ENABLE_TLS=false
|
|
156
924
|
SMTP_ENABLE_AUTH=true
|
|
157
925
|
SMTP_DB_PATH=/var/lib/smtp-server/smtp.db
|
|
158
|
-
AWS_S3_BUCKET=${
|
|
159
|
-
AWS_REGION=${
|
|
926
|
+
AWS_S3_BUCKET=${emailBucketName}
|
|
927
|
+
AWS_REGION=${region}
|
|
160
928
|
SMTP_ENABLE_JSON_LOGGING=true
|
|
161
929
|
SMTP_LOG_LEVEL=info
|
|
162
930
|
SMTP_MAILBOX_PATH=/var/spool/mail
|
|
@@ -167,7 +935,9 @@ SMTP_MAX_RECIPIENTS=100
|
|
|
167
935
|
SMTP_RATE_LIMIT_PER_IP=100
|
|
168
936
|
SMTP_RATE_LIMIT_PER_USER=200
|
|
169
937
|
SMTP_IO_MODE=epoll
|
|
170
|
-
ENVEOF`,
|
|
938
|
+
ENVEOF`,
|
|
939
|
+
"chmod 600 /etc/smtp-server/smtp-server.env",
|
|
940
|
+
`cat > /etc/systemd/system/smtp-server.service << 'SVCEOF'
|
|
171
941
|
[Unit]
|
|
172
942
|
Description=Stacks Mail Server (Zig)
|
|
173
943
|
After=network.target
|
|
@@ -185,20 +955,99 @@ SyslogIdentifier=smtp-server
|
|
|
185
955
|
|
|
186
956
|
[Install]
|
|
187
957
|
WantedBy=multi-user.target
|
|
188
|
-
SVCEOF`,
|
|
189
|
-
|
|
958
|
+
SVCEOF`,
|
|
959
|
+
"systemctl daemon-reload",
|
|
960
|
+
"systemctl enable smtp-server",
|
|
961
|
+
"systemctl restart smtp-server",
|
|
962
|
+
"sleep 3",
|
|
963
|
+
'systemctl is-active smtp-server && echo "Mail server is running" || echo "Mail server failed to start"',
|
|
964
|
+
'ss -tlnp | grep -E "(25|143)" || echo "Warning: mail ports not listening yet"'
|
|
965
|
+
].join(" && "), cmdId = (await awsClient.request({
|
|
966
|
+
service: "ssm",
|
|
967
|
+
region,
|
|
968
|
+
method: "POST",
|
|
969
|
+
path: "/",
|
|
970
|
+
headers: {
|
|
971
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
972
|
+
"X-Amz-Target": "AmazonSSM.SendCommand"
|
|
973
|
+
},
|
|
974
|
+
body: JSON.stringify({
|
|
975
|
+
InstanceIds: [smtpInstanceId],
|
|
976
|
+
DocumentName: "AWS-RunShellScript",
|
|
977
|
+
Parameters: { commands: [zigSsmCommand] },
|
|
978
|
+
TimeoutSeconds: 300
|
|
979
|
+
})
|
|
980
|
+
}))?.Command?.CommandId;
|
|
981
|
+
if (cmdId) {
|
|
982
|
+
let ssmAttempts = 0;
|
|
983
|
+
while (ssmAttempts < 100) {
|
|
984
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
985
|
+
try {
|
|
986
|
+
const invocation = await awsClient.request({
|
|
987
|
+
service: "ssm",
|
|
988
|
+
region,
|
|
989
|
+
method: "POST",
|
|
990
|
+
path: "/",
|
|
991
|
+
headers: {
|
|
992
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
993
|
+
"X-Amz-Target": "AmazonSSM.GetCommandInvocation"
|
|
994
|
+
},
|
|
995
|
+
body: JSON.stringify({ CommandId: cmdId, InstanceId: smtpInstanceId })
|
|
996
|
+
}), status = invocation?.Status;
|
|
997
|
+
if (status === "Success") {
|
|
998
|
+
smtpSpinner.succeed("Zig mail server deployed to EC2");
|
|
999
|
+
if (isVerbose && invocation?.StandardOutputContent)
|
|
1000
|
+
console.log(invocation.StandardOutputContent);
|
|
1001
|
+
break;
|
|
1002
|
+
} else if (status === "Failed" || status === "Cancelled" || status === "TimedOut") {
|
|
1003
|
+
const errOutput = invocation?.StandardErrorContent || invocation?.StandardOutputContent || "Unknown error";
|
|
1004
|
+
smtpSpinner.fail(`Zig mail server deploy failed: ${errOutput.slice(0, 500)}`);
|
|
1005
|
+
break;
|
|
1006
|
+
}
|
|
1007
|
+
} catch {}
|
|
1008
|
+
ssmAttempts++;
|
|
1009
|
+
}
|
|
1010
|
+
if (ssmAttempts >= 100)
|
|
1011
|
+
smtpSpinner.fail("Zig mail server deploy timed out");
|
|
1012
|
+
} else
|
|
1013
|
+
smtpSpinner.fail("Failed to send SSM command for mail server");
|
|
1014
|
+
} else
|
|
1015
|
+
smtpSpinner.fail(`No Pantry-provided ${MAIL_TARGET_PLATFORM} mail binary found. Release ${MAIL_PACKAGE_DOMAIN}, then run the Pantry binary sync for that package.`);
|
|
1016
|
+
} else {
|
|
1017
|
+
const smtpServerSrc = resolvePath(p.projectPath("storage/framework/core/cloud/src/imap/smtp-server.ts")), bundleOutDir = joinPath(p.projectPath(), ".stacks/tmp/smtp-bundle");
|
|
1018
|
+
execSync(`bun build ${smtpServerSrc} --target=bun --outdir=${bundleOutDir}`, { stdio: "pipe" });
|
|
1019
|
+
const bundlePath = joinPath(bundleOutDir, "smtp-server.js");
|
|
1020
|
+
if (smtpFileExists(bundlePath)) {
|
|
1021
|
+
const smtpBundle = readSmtpFile(bundlePath), mailboxes = emailConfig.mailboxes || ["chris", "blake", "glenn"], usersEntries = mailboxes.map((name) => {
|
|
1022
|
+
const envKey = `MAIL_PASSWORD_${name.toUpperCase()}`;
|
|
1023
|
+
return ` "${name}": { password: process.env["${envKey}"] || "", email: "${name}@${domain}" }`;
|
|
1024
|
+
}).join(`,
|
|
1025
|
+
`), entryPoint = `import { SmtpServer } from "./smtp-server.js";
|
|
190
1026
|
import * as fs from "fs";
|
|
191
|
-
const domain = "${
|
|
192
|
-
const users = { ${
|
|
1027
|
+
const domain = "${domain}";
|
|
1028
|
+
const users = { ${usersEntries} };
|
|
193
1029
|
const certDir = \`/etc/letsencrypt/live/mail.\${domain}\`;
|
|
194
1030
|
let tlsConfig = undefined;
|
|
195
1031
|
if (fs.existsSync(\`\${certDir}/privkey.pem\`)) {
|
|
196
1032
|
tlsConfig = { key: \`\${certDir}/privkey.pem\`, cert: \`\${certDir}/fullchain.pem\` };
|
|
197
1033
|
}
|
|
198
|
-
const server = new SmtpServer({ port: 587, tlsPort: 465, host: "0.0.0.0", domain, users, tls: tlsConfig, sentBucket: "${
|
|
1034
|
+
const server = new SmtpServer({ port: 587, tlsPort: 465, host: "0.0.0.0", domain, users, tls: tlsConfig, sentBucket: "${projectName}-production-email", sentPrefix: "sent/" });
|
|
199
1035
|
await server.start();
|
|
200
|
-
`;
|
|
201
|
-
|
|
1036
|
+
`;
|
|
1037
|
+
await s3.putObject({ bucket: smtpBucketName, key: "_deploy/smtp-server.js", body: smtpBundle, contentType: "application/javascript" });
|
|
1038
|
+
await s3.putObject({ bucket: smtpBucketName, key: "_deploy/smtp-entry.ts", body: Buffer.from(entryPoint), contentType: "application/typescript" });
|
|
1039
|
+
const envLines = mailboxes.map((name) => {
|
|
1040
|
+
const envKey = `MAIL_PASSWORD_${name.toUpperCase()}`;
|
|
1041
|
+
return `Environment=${envKey}=${process.env[envKey] || ""}`;
|
|
1042
|
+
}).join(`
|
|
1043
|
+
`), smtpSsmCommand = [
|
|
1044
|
+
"mkdir -p /var/www/smtp",
|
|
1045
|
+
`aws s3 cp s3://${smtpBucketName}/_deploy/smtp-server.js /var/www/smtp/smtp-server.js`,
|
|
1046
|
+
`aws s3 cp s3://${smtpBucketName}/_deploy/smtp-entry.ts /var/www/smtp/smtp-entry.ts`,
|
|
1047
|
+
firewallOpenCommand(mailPorts),
|
|
1048
|
+
"if ! command -v certbot &> /dev/null; then dnf install -y certbot || yum install -y certbot || apt-get install -y certbot || true; fi",
|
|
1049
|
+
`if [ ! -d /etc/letsencrypt/live/mail.${domain} ]; then certbot certonly --standalone --non-interactive --agree-tos --email admin@${domain} -d mail.${domain} --http-01-port 80 || echo "Certbot failed"; fi`,
|
|
1050
|
+
`cat > /etc/systemd/system/stacks-smtp.service << 'SERVICEFILE'
|
|
202
1051
|
[Unit]
|
|
203
1052
|
Description=Stacks SMTP Proxy Server
|
|
204
1053
|
After=network.target
|
|
@@ -209,11 +1058,115 @@ ExecStart=/root/.bun/bin/bun smtp-entry.ts
|
|
|
209
1058
|
Restart=on-failure
|
|
210
1059
|
RestartSec=5
|
|
211
1060
|
Environment=NODE_ENV=production
|
|
212
|
-
Environment=AWS_REGION=${
|
|
213
|
-
${
|
|
1061
|
+
Environment=AWS_REGION=${region}
|
|
1062
|
+
${envLines}
|
|
214
1063
|
[Install]
|
|
215
1064
|
WantedBy=multi-user.target
|
|
216
|
-
SERVICEFILE`,
|
|
1065
|
+
SERVICEFILE`,
|
|
1066
|
+
"systemctl daemon-reload",
|
|
1067
|
+
"systemctl enable stacks-smtp",
|
|
1068
|
+
"systemctl restart stacks-smtp"
|
|
1069
|
+
].join(" && "), cmdId = (await awsClient.request({
|
|
1070
|
+
service: "ssm",
|
|
1071
|
+
region,
|
|
1072
|
+
method: "POST",
|
|
1073
|
+
path: "/",
|
|
1074
|
+
headers: { "Content-Type": "application/x-amz-json-1.1", "X-Amz-Target": "AmazonSSM.SendCommand" },
|
|
1075
|
+
body: JSON.stringify({ InstanceIds: [smtpInstanceId], DocumentName: "AWS-RunShellScript", Parameters: { commands: [smtpSsmCommand] }, TimeoutSeconds: 180 })
|
|
1076
|
+
}))?.Command?.CommandId;
|
|
1077
|
+
if (cmdId) {
|
|
1078
|
+
let ssmAttempts = 0;
|
|
1079
|
+
while (ssmAttempts < 60) {
|
|
1080
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
1081
|
+
try {
|
|
1082
|
+
const invocation = await awsClient.request({
|
|
1083
|
+
service: "ssm",
|
|
1084
|
+
region,
|
|
1085
|
+
method: "POST",
|
|
1086
|
+
path: "/",
|
|
1087
|
+
headers: { "Content-Type": "application/x-amz-json-1.1", "X-Amz-Target": "AmazonSSM.GetCommandInvocation" },
|
|
1088
|
+
body: JSON.stringify({ CommandId: cmdId, InstanceId: smtpInstanceId })
|
|
1089
|
+
}), status = invocation?.Status;
|
|
1090
|
+
if (status === "Success") {
|
|
1091
|
+
smtpSpinner.succeed("SMTP proxy deployed to EC2");
|
|
1092
|
+
break;
|
|
1093
|
+
} else if (status === "Failed" || status === "Cancelled" || status === "TimedOut") {
|
|
1094
|
+
smtpSpinner.fail(`SMTP proxy deploy failed: ${invocation?.StandardErrorContent || "Unknown"}`);
|
|
1095
|
+
break;
|
|
1096
|
+
}
|
|
1097
|
+
} catch {}
|
|
1098
|
+
ssmAttempts++;
|
|
1099
|
+
}
|
|
1100
|
+
if (ssmAttempts >= 60)
|
|
1101
|
+
smtpSpinner.fail("SMTP proxy deploy timed out");
|
|
1102
|
+
} else
|
|
1103
|
+
smtpSpinner.fail("Failed to send SSM command for SMTP proxy");
|
|
1104
|
+
try {
|
|
1105
|
+
await s3.deleteObject({ bucket: smtpBucketName, key: "_deploy/smtp-server.js" });
|
|
1106
|
+
await s3.deleteObject({ bucket: smtpBucketName, key: "_deploy/smtp-entry.ts" });
|
|
1107
|
+
} catch {}
|
|
1108
|
+
} else {
|
|
1109
|
+
smtpSpinner.stop();
|
|
1110
|
+
if (isVerbose)
|
|
1111
|
+
log.debug("SMTP server bundle not found, skipping");
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
} else {
|
|
1115
|
+
smtpSpinner.stop();
|
|
1116
|
+
if (isVerbose)
|
|
1117
|
+
log.debug("No EC2 instance or bucket found, skipping mail server deploy");
|
|
1118
|
+
}
|
|
1119
|
+
} catch (smtpDeployError) {
|
|
1120
|
+
smtpSpinner.fail(`Mail server deployment failed: ${smtpDeployError.message}`);
|
|
1121
|
+
if (isVerbose)
|
|
1122
|
+
log.debug(`Mail server deploy error: ${smtpDeployError.stack}`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
const frontendSpinner = spinner("Deploying frontend to S3...");
|
|
1127
|
+
frontendSpinner.start();
|
|
1128
|
+
try {
|
|
1129
|
+
const { S3Client, CloudFormationClient } = await import("@stacksjs/ts-cloud"), { readFileSync } = await import("node:fs"), { buildFrontendStatic } = await import("../build/frontend-static"), s3 = new S3Client(region), cf = new CloudFormationClient(region), stackName = `${projectName}-cloud`, stackOutputs = await cf.getStackOutputs(stackName), bucketName = stackOutputs.FrontendBucketName;
|
|
1130
|
+
if (!bucketName) {
|
|
1131
|
+
frontendSpinner.stop();
|
|
1132
|
+
console.log("\u26A0 Frontend bucket not found in stack outputs (add storage.public to infrastructure config)");
|
|
1133
|
+
} else {
|
|
1134
|
+
const buildDir = await buildFrontendStatic();
|
|
1135
|
+
await (async (dir, _prefix = "") => {
|
|
1136
|
+
await traverseDirectory(dir, async (filePath, key) => {
|
|
1137
|
+
const content = readFileSync(filePath), contentType = getMimeType(filePath), cacheControl = key === "index.html" || key.endsWith("/index.html") ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000, immutable";
|
|
1138
|
+
await withS3Retry(() => s3.putObject({ bucket: bucketName, key, body: content, contentType, cacheControl }), `upload ${key}`);
|
|
1139
|
+
if (isVerbose)
|
|
1140
|
+
log.debug(` Uploaded: ${key}`);
|
|
1141
|
+
});
|
|
1142
|
+
})(buildDir);
|
|
1143
|
+
const bootstrapPath = p.projectPath("bootstrap");
|
|
1144
|
+
if (existsSync(bootstrapPath)) {
|
|
1145
|
+
const bootstrapContent = readFileSync(bootstrapPath);
|
|
1146
|
+
await withS3Retry(() => s3.putObject({
|
|
1147
|
+
bucket: bucketName,
|
|
1148
|
+
key: "install",
|
|
1149
|
+
body: bootstrapContent,
|
|
1150
|
+
contentType: "text/plain",
|
|
1151
|
+
cacheControl: "no-cache, no-store, must-revalidate"
|
|
1152
|
+
}), "upload bootstrap install script");
|
|
1153
|
+
if (isVerbose)
|
|
1154
|
+
log.debug(" Uploaded bootstrap install script");
|
|
1155
|
+
}
|
|
1156
|
+
const distributionId = stackOutputs.publicCloudFrontDistributionId || stackOutputs.CloudFrontDistributionId;
|
|
1157
|
+
if (distributionId) {
|
|
1158
|
+
if (isVerbose)
|
|
1159
|
+
log.debug(" Invalidating CloudFront cache...");
|
|
1160
|
+
const { AWSClient } = await import("@stacksjs/ts-cloud");
|
|
1161
|
+
await new AWSClient().request({
|
|
1162
|
+
service: "cloudfront",
|
|
1163
|
+
region: "us-east-1",
|
|
1164
|
+
method: "POST",
|
|
1165
|
+
path: `/2020-05-31/distribution/${distributionId}/invalidation`,
|
|
1166
|
+
headers: {
|
|
1167
|
+
"Content-Type": "application/xml"
|
|
1168
|
+
},
|
|
1169
|
+
body: `<?xml version="1.0" encoding="UTF-8"?>
|
|
217
1170
|
<InvalidationBatch xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
|
218
1171
|
<CallerReference>${Date.now()}</CallerReference>
|
|
219
1172
|
<Paths>
|
|
@@ -222,27 +1175,131 @@ SERVICEFILE`,"systemctl daemon-reload","systemctl enable stacks-smtp","systemctl
|
|
|
222
1175
|
<Path>/*</Path>
|
|
223
1176
|
</Items>
|
|
224
1177
|
</Paths>
|
|
225
|
-
</InvalidationBatch>`
|
|
1178
|
+
</InvalidationBatch>`
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
frontendSpinner.succeed(`Frontend deployed to S3 (${bucketName})`);
|
|
1182
|
+
}
|
|
1183
|
+
} catch (frontendError) {
|
|
1184
|
+
frontendSpinner.fail(`Frontend deployment failed: ${frontendError.message}`);
|
|
1185
|
+
if (isVerbose)
|
|
1186
|
+
log.debug(`Frontend error: ${frontendError.stack}`);
|
|
1187
|
+
}
|
|
1188
|
+
const docsDistPath = p.projectPath("dist/docs/.bunpress"), { existsSync: docsExists } = await import("node:fs");
|
|
1189
|
+
if (docsExists(docsDistPath)) {
|
|
1190
|
+
const docsDeploySpinner = spinner("Deploying documentation to S3...");
|
|
1191
|
+
docsDeploySpinner.start();
|
|
1192
|
+
try {
|
|
1193
|
+
const { S3Client, CloudFormationClient } = await import("@stacksjs/ts-cloud"), { readFileSync } = await import("node:fs"), s3 = new S3Client(region), cf = new CloudFormationClient(region), stackName = `${projectName}-cloud`, docsOutputs = await cf.getStackOutputs(stackName), docsBucketName = docsOutputs.DocsBucketName;
|
|
1194
|
+
if (!docsBucketName) {
|
|
1195
|
+
docsDeploySpinner.stop();
|
|
1196
|
+
console.log("\u26A0 Docs bucket not found in stack outputs (infrastructure may not be updated yet)");
|
|
1197
|
+
} else {
|
|
1198
|
+
await (async (dir, _prefix = "") => {
|
|
1199
|
+
await traverseDirectory(dir, async (filePath, key) => {
|
|
1200
|
+
const content = readFileSync(filePath), contentType = getMimeType(filePath), cacheControl = key.endsWith(".html") ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000, immutable";
|
|
1201
|
+
await withS3Retry(() => s3.putObject({ bucket: docsBucketName, key, body: content, contentType, cacheControl }), `upload docs ${key}`);
|
|
1202
|
+
if (isVerbose)
|
|
1203
|
+
log.debug(` Uploaded docs: ${key}`);
|
|
1204
|
+
}, "", [".DS_Store", ".bunpress"]);
|
|
1205
|
+
})(docsDistPath);
|
|
1206
|
+
const docsDistributionId = docsOutputs.docsCloudFrontDistributionId || docsOutputs.publicCloudFrontDistributionId || docsOutputs.CloudFrontDistributionId;
|
|
1207
|
+
if (docsDistributionId) {
|
|
1208
|
+
if (isVerbose)
|
|
1209
|
+
log.debug(" Invalidating CloudFront cache for docs...");
|
|
1210
|
+
const { AWSClient } = await import("@stacksjs/ts-cloud"), client = new AWSClient, docsInvalidationPaths = docsOutputs.docsCloudFrontDistributionId ? ["/*"] : ["/docs", "/docs/*"];
|
|
1211
|
+
await client.request({
|
|
1212
|
+
service: "cloudfront",
|
|
1213
|
+
region: "us-east-1",
|
|
1214
|
+
method: "POST",
|
|
1215
|
+
path: `/2020-05-31/distribution/${docsDistributionId}/invalidation`,
|
|
1216
|
+
headers: {
|
|
1217
|
+
"Content-Type": "application/xml"
|
|
1218
|
+
},
|
|
1219
|
+
body: `<?xml version="1.0" encoding="UTF-8"?>
|
|
226
1220
|
<InvalidationBatch xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
|
227
1221
|
<CallerReference>docs-${Date.now()}</CallerReference>
|
|
228
1222
|
<Paths>
|
|
229
|
-
<Quantity>${
|
|
1223
|
+
<Quantity>${docsInvalidationPaths.length}</Quantity>
|
|
230
1224
|
<Items>
|
|
231
|
-
${
|
|
1225
|
+
${docsInvalidationPaths.map((path) => `<Path>${path}</Path>`).join(`
|
|
232
1226
|
`)}
|
|
233
1227
|
</Items>
|
|
234
1228
|
</Paths>
|
|
235
|
-
</InvalidationBatch>`
|
|
1229
|
+
</InvalidationBatch>`
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
docsDeploySpinner.succeed(`Documentation deployed to S3 (${docsBucketName})`);
|
|
1233
|
+
}
|
|
1234
|
+
} catch (docsDeployError) {
|
|
1235
|
+
docsDeploySpinner.fail(`Documentation deployment failed: ${docsDeployError.message}`);
|
|
1236
|
+
if (isVerbose)
|
|
1237
|
+
log.debug(`Docs deploy error: ${docsDeployError.stack}`);
|
|
1238
|
+
}
|
|
1239
|
+
} else if (isVerbose)
|
|
1240
|
+
log.debug("No docs build found at dist/docs, skipping docs deployment");
|
|
1241
|
+
const blogDistPath = p.projectPath("dist/blog"), { existsSync: blogExists } = await import("node:fs");
|
|
1242
|
+
if (blogExists(blogDistPath)) {
|
|
1243
|
+
const blogDeploySpinner = spinner("Deploying blog to S3...");
|
|
1244
|
+
blogDeploySpinner.start();
|
|
1245
|
+
try {
|
|
1246
|
+
const { S3Client: BlogS3Client, CloudFormationClient: BlogCFClient } = await import("@stacksjs/ts-cloud"), { readFileSync: readBlogFile } = await import("node:fs"), blogS3 = new BlogS3Client(region), blogCf = new BlogCFClient(region), stackName = `${projectName}-cloud`, outputs = await blogCf.getStackOutputs(stackName), blogBucketName = outputs.BlogBucketName;
|
|
1247
|
+
if (!blogBucketName) {
|
|
1248
|
+
blogDeploySpinner.stop();
|
|
1249
|
+
console.log("\u26A0 Blog bucket not found in stack outputs");
|
|
1250
|
+
} else {
|
|
1251
|
+
await traverseDirectory(blogDistPath, async (filePath, key) => {
|
|
1252
|
+
const content = readBlogFile(filePath), contentType = getMimeType(filePath), cacheControl = key.endsWith(".html") ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000, immutable";
|
|
1253
|
+
await withS3Retry(() => blogS3.putObject({ bucket: blogBucketName, key, body: content, contentType, cacheControl }), `upload blog ${key}`);
|
|
1254
|
+
if (isVerbose)
|
|
1255
|
+
log.debug(` Uploaded blog: ${key}`);
|
|
1256
|
+
}, "", [".DS_Store"]);
|
|
1257
|
+
const blogDistributionId = outputs.blogCloudFrontDistributionId || outputs.publicCloudFrontDistributionId || outputs.CloudFrontDistributionId;
|
|
1258
|
+
if (blogDistributionId) {
|
|
1259
|
+
if (isVerbose)
|
|
1260
|
+
log.debug(" Invalidating CloudFront cache for blog...");
|
|
1261
|
+
const { AWSClient: BlogAWSClient } = await import("@stacksjs/ts-cloud"), blogClient = new BlogAWSClient, blogInvalidationPaths = outputs.blogCloudFrontDistributionId ? ["/*"] : ["/blog", "/blog/*"];
|
|
1262
|
+
await blogClient.request({
|
|
1263
|
+
service: "cloudfront",
|
|
1264
|
+
region: "us-east-1",
|
|
1265
|
+
method: "POST",
|
|
1266
|
+
path: `/2020-05-31/distribution/${blogDistributionId}/invalidation`,
|
|
1267
|
+
headers: {
|
|
1268
|
+
"Content-Type": "application/xml"
|
|
1269
|
+
},
|
|
1270
|
+
body: `<?xml version="1.0" encoding="UTF-8"?>
|
|
236
1271
|
<InvalidationBatch xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
|
237
1272
|
<CallerReference>blog-${Date.now()}</CallerReference>
|
|
238
1273
|
<Paths>
|
|
239
|
-
<Quantity>${
|
|
1274
|
+
<Quantity>${blogInvalidationPaths.length}</Quantity>
|
|
240
1275
|
<Items>
|
|
241
|
-
${
|
|
1276
|
+
${blogInvalidationPaths.map((path) => `<Path>${path}</Path>`).join(`
|
|
242
1277
|
`)}
|
|
243
1278
|
</Items>
|
|
244
1279
|
</Paths>
|
|
245
|
-
</InvalidationBatch>`
|
|
1280
|
+
</InvalidationBatch>`
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
blogDeploySpinner.succeed(`Blog deployed to S3 (${blogBucketName})`);
|
|
1284
|
+
}
|
|
1285
|
+
} catch (blogDeployError) {
|
|
1286
|
+
blogDeploySpinner.fail(`Blog deployment failed: ${blogDeployError.message}`);
|
|
1287
|
+
if (isVerbose)
|
|
1288
|
+
log.debug(`Blog deploy error: ${blogDeployError.stack}`);
|
|
1289
|
+
}
|
|
1290
|
+
} else if (isVerbose)
|
|
1291
|
+
log.debug("No blog build found at dist/blog, skipping blog deployment");
|
|
1292
|
+
const errorPageSpinner = spinner("Configuring 404 error page...");
|
|
1293
|
+
errorPageSpinner.start();
|
|
1294
|
+
try {
|
|
1295
|
+
const { S3Client, CloudFormationClient, AWSClient } = await import("@stacksjs/ts-cloud"), { existsSync: exists404, readFileSync: read404 } = await import("node:fs"), s3 = new S3Client(region), cf = new CloudFormationClient(region), awsClient = new AWSClient, stackName = `${projectName}-cloud`, outputs = await cf.getStackOutputs(stackName), frontendBucket = outputs.FrontendBucketName, docsBucket = outputs.DocsBucketName, distributionId = outputs.publicCloudFrontDistributionId || outputs.CloudFrontDistributionId, docs404Path = p.projectPath("dist/docs/.bunpress/404.html");
|
|
1296
|
+
let html404Content;
|
|
1297
|
+
if (exists404(docs404Path)) {
|
|
1298
|
+
html404Content = read404(docs404Path, "utf-8");
|
|
1299
|
+
if (isVerbose)
|
|
1300
|
+
log.debug(" Using BunPress-generated 404.html");
|
|
1301
|
+
} else {
|
|
1302
|
+
html404Content = `<!DOCTYPE html>
|
|
246
1303
|
<html lang="en">
|
|
247
1304
|
<head>
|
|
248
1305
|
<meta charset="UTF-8">
|
|
@@ -289,12 +1346,229 @@ SERVICEFILE`,"systemctl daemon-reload","systemctl enable stacks-smtp","systemctl
|
|
|
289
1346
|
<div class="action"><a class="link" href="/docs/">Take me home</a></div>
|
|
290
1347
|
</div>
|
|
291
1348
|
</body>
|
|
292
|
-
</html
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
1349
|
+
</html>`;
|
|
1350
|
+
if (isVerbose)
|
|
1351
|
+
log.debug(" Using default 404.html template");
|
|
1352
|
+
}
|
|
1353
|
+
if (frontendBucket) {
|
|
1354
|
+
await withS3Retry(() => s3.putObject({
|
|
1355
|
+
bucket: frontendBucket,
|
|
1356
|
+
key: "404.html",
|
|
1357
|
+
body: html404Content,
|
|
1358
|
+
contentType: "text/html",
|
|
1359
|
+
cacheControl: "no-cache, no-store, must-revalidate"
|
|
1360
|
+
}), "upload 404.html to frontend bucket");
|
|
1361
|
+
if (isVerbose)
|
|
1362
|
+
log.debug(` Uploaded 404.html to frontend bucket: ${frontendBucket}`);
|
|
1363
|
+
}
|
|
1364
|
+
if (docsBucket) {
|
|
1365
|
+
await withS3Retry(() => s3.putObject({
|
|
1366
|
+
bucket: docsBucket,
|
|
1367
|
+
key: "404.html",
|
|
1368
|
+
body: html404Content,
|
|
1369
|
+
contentType: "text/html",
|
|
1370
|
+
cacheControl: "no-cache, no-store, must-revalidate"
|
|
1371
|
+
}), "upload 404.html to docs bucket");
|
|
1372
|
+
if (isVerbose)
|
|
1373
|
+
log.debug(` Uploaded 404.html to docs bucket: ${docsBucket}`);
|
|
1374
|
+
}
|
|
1375
|
+
if (distributionId) {
|
|
1376
|
+
if (isVerbose)
|
|
1377
|
+
log.debug(" Configuring CloudFront custom error responses...");
|
|
1378
|
+
const getResult = await awsClient.request({
|
|
1379
|
+
service: "cloudfront",
|
|
1380
|
+
region: "us-east-1",
|
|
1381
|
+
method: "GET",
|
|
1382
|
+
path: `/2020-05-31/distribution/${distributionId}/config`,
|
|
1383
|
+
returnHeaders: !0
|
|
1384
|
+
}), etag = getResult.headers?.etag || getResult.headers?.ETag || "", currentConfig = getResult.body?.DistributionConfig || getResult.DistributionConfig;
|
|
1385
|
+
if (currentConfig && etag) {
|
|
1386
|
+
if (!((currentConfig.CustomErrorResponses?.Quantity || 0) > 0 && currentConfig.CustomErrorResponses?.Items?.CustomErrorResponse?.some?.((err) => err.ErrorCode === 404 && err.ResponsePagePath === "/404.html"))) {
|
|
1387
|
+
currentConfig.CustomErrorResponses = {
|
|
1388
|
+
Quantity: 2,
|
|
1389
|
+
Items: {
|
|
1390
|
+
CustomErrorResponse: [
|
|
1391
|
+
{
|
|
1392
|
+
ErrorCode: 403,
|
|
1393
|
+
ResponsePagePath: "/404.html",
|
|
1394
|
+
ResponseCode: 404,
|
|
1395
|
+
ErrorCachingMinTTL: 10
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
ErrorCode: 404,
|
|
1399
|
+
ResponsePagePath: "/404.html",
|
|
1400
|
+
ResponseCode: 404,
|
|
1401
|
+
ErrorCachingMinTTL: 10
|
|
1402
|
+
}
|
|
1403
|
+
]
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
const buildXmlElement = (name, value, indent = "") => {
|
|
1407
|
+
if (value === null || value === void 0)
|
|
1408
|
+
return "";
|
|
1409
|
+
if (typeof value === "boolean")
|
|
1410
|
+
return `${indent}<${name}>${value}</${name}>
|
|
1411
|
+
`;
|
|
1412
|
+
if (typeof value === "number" || typeof value === "string")
|
|
1413
|
+
return `${indent}<${name}>${value}</${name}>
|
|
1414
|
+
`;
|
|
1415
|
+
if (Array.isArray(value))
|
|
1416
|
+
return value.map((item) => buildXmlElement(name, item, indent)).join("");
|
|
1417
|
+
if (typeof value === "object") {
|
|
1418
|
+
if (name.startsWith("@_") || name === "?xml")
|
|
1419
|
+
return "";
|
|
1420
|
+
let children = "";
|
|
1421
|
+
for (const [key, val] of Object.entries(value))
|
|
1422
|
+
if (!key.startsWith("@_") && key !== "#text")
|
|
1423
|
+
children += buildXmlElement(key, val, `${indent} `);
|
|
1424
|
+
if (value["#text"] !== void 0)
|
|
1425
|
+
return `${indent}<${name}>${value["#text"]}</${name}>
|
|
1426
|
+
`;
|
|
1427
|
+
return `${indent}<${name}>
|
|
1428
|
+
${children}${indent}</${name}>
|
|
1429
|
+
`;
|
|
1430
|
+
}
|
|
1431
|
+
return "";
|
|
1432
|
+
}, xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
298
1433
|
<DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
|
299
|
-
${Object.entries(
|
|
300
|
-
`;
|
|
1434
|
+
${Object.entries(currentConfig).filter(([key]) => !key.startsWith("@_")).map(([key, val]) => buildXmlElement(key, val, " ")).join("")}</DistributionConfig>
|
|
1435
|
+
`;
|
|
1436
|
+
await awsClient.request({
|
|
1437
|
+
service: "cloudfront",
|
|
1438
|
+
region: "us-east-1",
|
|
1439
|
+
method: "PUT",
|
|
1440
|
+
path: `/2020-05-31/distribution/${distributionId}/config`,
|
|
1441
|
+
body: xml,
|
|
1442
|
+
headers: {
|
|
1443
|
+
"Content-Type": "application/xml",
|
|
1444
|
+
"If-Match": etag
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
if (isVerbose)
|
|
1448
|
+
log.debug(" CloudFront custom error responses configured");
|
|
1449
|
+
} else if (isVerbose)
|
|
1450
|
+
log.debug(" CloudFront custom error responses already configured");
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
errorPageSpinner.succeed("404 error page configured");
|
|
1454
|
+
} catch (errorPageError) {
|
|
1455
|
+
errorPageSpinner.stop();
|
|
1456
|
+
console.log(`\u26A0 404 page configuration skipped: ${errorPageError.message}`);
|
|
1457
|
+
if (isVerbose)
|
|
1458
|
+
log.debug(`Error page config error: ${errorPageError.stack}`);
|
|
1459
|
+
}
|
|
1460
|
+
try {
|
|
1461
|
+
const tunnelCloudConfig = await import(p.projectPath("config/cloud")), tunnelConfig = tunnelCloudConfig?.tsCloud?.infrastructure?.tunnel;
|
|
1462
|
+
if (tunnelConfig?.enabled) {
|
|
1463
|
+
const tunnelDomain = tunnelConfig.domain || "";
|
|
1464
|
+
if (!tunnelDomain || tunnelDomain === "localtunnel.dev" || tunnelDomain === "api.localtunnel.dev")
|
|
1465
|
+
console.log("\u2139 Tunnel: Using shared localtunnel.dev (no custom tunnel deployment needed)");
|
|
1466
|
+
else {
|
|
1467
|
+
const tunnelSpinner = spinner(`Deploying tunnel server to ${tunnelDomain}...`);
|
|
1468
|
+
tunnelSpinner.start();
|
|
1469
|
+
try {
|
|
1470
|
+
const { deployTunnelServer } = await import("@stacksjs/tunnel");
|
|
1471
|
+
await deployTunnelServer({
|
|
1472
|
+
domain: tunnelDomain,
|
|
1473
|
+
region: tunnelConfig.region || tunnelCloudConfig?.tsCloud?.project?.region || "us-east-1",
|
|
1474
|
+
instanceType: tunnelConfig.instanceType,
|
|
1475
|
+
prefix: tunnelConfig.prefix,
|
|
1476
|
+
enableSsl: tunnelConfig.ssl?.enabled,
|
|
1477
|
+
porkbunApiKey: tunnelConfig.ssl?.porkbunApiKey,
|
|
1478
|
+
porkbunSecretKey: tunnelConfig.ssl?.porkbunSecretKey,
|
|
1479
|
+
verbose: isVerbose
|
|
1480
|
+
});
|
|
1481
|
+
tunnelSpinner.succeed(`Tunnel server deployed to ${tunnelDomain}`);
|
|
1482
|
+
} catch (tunnelError) {
|
|
1483
|
+
tunnelSpinner.stop();
|
|
1484
|
+
console.log(`\u26A0 Tunnel deployment skipped: ${tunnelError.message}`);
|
|
1485
|
+
if (isVerbose)
|
|
1486
|
+
log.debug(`Tunnel deploy error: ${tunnelError.stack}`);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
} catch {}
|
|
1491
|
+
const dnsSpinner = spinner("Reconciling DNS records...");
|
|
1492
|
+
dnsSpinner.start();
|
|
1493
|
+
try {
|
|
1494
|
+
const { Route53Client, CloudFormationClient: DnsCfClient } = await import("@stacksjs/ts-cloud"), dnsCf = new DnsCfClient(region), dnsStackName = `${projectName}-cloud`, dnsOutputs = await dnsCf.getStackOutputs(dnsStackName), dnsCloudConfig = await import(p.projectPath("config/cloud")), hostedZoneId = dnsCloudConfig?.tsCloud?.infrastructure?.dns?.hostedZoneId, siteDomain = dnsCloudConfig?.tsCloud?.infrastructure?.dns?.domain || "stacksjs.com";
|
|
1495
|
+
if (hostedZoneId) {
|
|
1496
|
+
const route53 = new Route53Client(region), cfHostedZoneId = "Z2FDTNDATAQYW2", domainMappings = [
|
|
1497
|
+
{ domain: siteDomain, cfDomain: dnsOutputs.publicCloudFrontDomain },
|
|
1498
|
+
{ domain: `www.${siteDomain}`, cfDomain: dnsOutputs.publicCloudFrontDomain },
|
|
1499
|
+
{ domain: `docs.${siteDomain}`, cfDomain: dnsOutputs.docsCloudFrontDomain },
|
|
1500
|
+
{ domain: `blog.${siteDomain}`, cfDomain: dnsOutputs.blogCloudFrontDomain }
|
|
1501
|
+
];
|
|
1502
|
+
let recordsCreated = 0;
|
|
1503
|
+
for (const { domain, cfDomain } of domainMappings) {
|
|
1504
|
+
if (!cfDomain)
|
|
1505
|
+
continue;
|
|
1506
|
+
try {
|
|
1507
|
+
if (!((await route53.listResourceRecordSets({
|
|
1508
|
+
HostedZoneId: hostedZoneId,
|
|
1509
|
+
StartRecordName: domain,
|
|
1510
|
+
StartRecordType: "A",
|
|
1511
|
+
MaxItems: "1"
|
|
1512
|
+
}))?.ResourceRecordSets || []).some((r) => r.Name === `${domain}.` && r.Type === "A")) {
|
|
1513
|
+
await route53.changeResourceRecordSets({
|
|
1514
|
+
HostedZoneId: hostedZoneId,
|
|
1515
|
+
ChangeBatch: {
|
|
1516
|
+
Changes: [
|
|
1517
|
+
{
|
|
1518
|
+
Action: "UPSERT",
|
|
1519
|
+
ResourceRecordSet: {
|
|
1520
|
+
Name: domain,
|
|
1521
|
+
Type: "A",
|
|
1522
|
+
AliasTarget: {
|
|
1523
|
+
HostedZoneId: cfHostedZoneId,
|
|
1524
|
+
DNSName: cfDomain,
|
|
1525
|
+
EvaluateTargetHealth: !1
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
},
|
|
1529
|
+
{
|
|
1530
|
+
Action: "UPSERT",
|
|
1531
|
+
ResourceRecordSet: {
|
|
1532
|
+
Name: domain,
|
|
1533
|
+
Type: "AAAA",
|
|
1534
|
+
AliasTarget: {
|
|
1535
|
+
HostedZoneId: cfHostedZoneId,
|
|
1536
|
+
DNSName: cfDomain,
|
|
1537
|
+
EvaluateTargetHealth: !1
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
]
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
recordsCreated++;
|
|
1545
|
+
if (isVerbose)
|
|
1546
|
+
log.debug(` Created DNS records: ${domain} -> ${cfDomain}`);
|
|
1547
|
+
} else if (isVerbose)
|
|
1548
|
+
log.debug(` DNS record exists: ${domain}`);
|
|
1549
|
+
} catch (dnsErr) {
|
|
1550
|
+
if (isVerbose)
|
|
1551
|
+
log.debug(` DNS check/create failed for ${domain}: ${dnsErr.message}`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
if (recordsCreated > 0)
|
|
1555
|
+
dnsSpinner.succeed(`DNS records reconciled (${recordsCreated} created)`);
|
|
1556
|
+
else
|
|
1557
|
+
dnsSpinner.succeed("DNS records verified");
|
|
1558
|
+
} else {
|
|
1559
|
+
dnsSpinner.stop();
|
|
1560
|
+
if (isVerbose)
|
|
1561
|
+
log.debug("No hosted zone ID configured, skipping DNS reconciliation");
|
|
1562
|
+
}
|
|
1563
|
+
} catch (dnsError) {
|
|
1564
|
+
dnsSpinner.stop();
|
|
1565
|
+
console.log(`\u26A0 DNS reconciliation skipped: ${dnsError.message}`);
|
|
1566
|
+
if (isVerbose)
|
|
1567
|
+
log.debug(`DNS error: ${dnsError.stack}`);
|
|
1568
|
+
}
|
|
1569
|
+
console.log("");
|
|
1570
|
+
log.success("Deployment completed successfully!");
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
log.error("Deployment failed:", error);
|
|
1573
|
+
process.exit(ExitCode.FatalError);
|
|
1574
|
+
}
|