@codaijs/keel 0.1.0
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/__tests__/cli.test.d.ts +2 -0
- package/dist/__tests__/cli.test.d.ts.map +1 -0
- package/dist/__tests__/cli.test.js +173 -0
- package/dist/__tests__/cli.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +86 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/sail-installer.test.d.ts +2 -0
- package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
- package/dist/__tests__/sail-installer.test.js +158 -0
- package/dist/__tests__/sail-installer.test.js.map +1 -0
- package/dist/create-runner.d.ts +11 -0
- package/dist/create-runner.d.ts.map +1 -0
- package/dist/create-runner.js +63 -0
- package/dist/create-runner.js.map +1 -0
- package/dist/create.d.ts +10 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +15 -0
- package/dist/create.js.map +1 -0
- package/dist/manage.d.ts +24 -0
- package/dist/manage.d.ts.map +1 -0
- package/dist/manage.js +1461 -0
- package/dist/manage.js.map +1 -0
- package/dist/prompts.d.ts +36 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +208 -0
- package/dist/prompts.js.map +1 -0
- package/dist/sail-installer.d.ts +37 -0
- package/dist/sail-installer.d.ts.map +1 -0
- package/dist/sail-installer.js +935 -0
- package/dist/sail-installer.js.map +1 -0
- package/dist/scaffold.d.ts +10 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +297 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +57 -0
- package/sails/_template/addon.json +20 -0
- package/sails/_template/install.ts +402 -0
- package/sails/admin-dashboard/README.md +117 -0
- package/sails/admin-dashboard/addon.json +28 -0
- package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
- package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
- package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
- package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
- package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
- package/sails/admin-dashboard/install.ts +305 -0
- package/sails/analytics/README.md +178 -0
- package/sails/analytics/addon.json +27 -0
- package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
- package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
- package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
- package/sails/analytics/install.ts +297 -0
- package/sails/file-uploads/README.md +191 -0
- package/sails/file-uploads/addon.json +30 -0
- package/sails/file-uploads/files/backend/routes/files.ts +198 -0
- package/sails/file-uploads/files/backend/schema/files.ts +36 -0
- package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
- package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
- package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
- package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
- package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
- package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
- package/sails/file-uploads/install.ts +466 -0
- package/sails/gdpr/README.md +174 -0
- package/sails/gdpr/addon.json +27 -0
- package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
- package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
- package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
- package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
- package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
- package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
- package/sails/gdpr/install.ts +756 -0
- package/sails/google-oauth/README.md +121 -0
- package/sails/google-oauth/addon.json +22 -0
- package/sails/google-oauth/files/GoogleButton.tsx +50 -0
- package/sails/google-oauth/install.ts +252 -0
- package/sails/i18n/README.md +193 -0
- package/sails/i18n/addon.json +30 -0
- package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
- package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
- package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
- package/sails/i18n/files/frontend/locales/de/common.json +44 -0
- package/sails/i18n/files/frontend/locales/en/common.json +44 -0
- package/sails/i18n/install.ts +407 -0
- package/sails/push-notifications/README.md +163 -0
- package/sails/push-notifications/addon.json +31 -0
- package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
- package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
- package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
- package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
- package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
- package/sails/push-notifications/install.ts +384 -0
- package/sails/r2-storage/README.md +101 -0
- package/sails/r2-storage/addon.json +29 -0
- package/sails/r2-storage/files/backend/services/storage.ts +71 -0
- package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
- package/sails/r2-storage/install.ts +412 -0
- package/sails/rate-limiting/README.md +145 -0
- package/sails/rate-limiting/addon.json +20 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
- package/sails/rate-limiting/install.ts +300 -0
- package/sails/registry.json +107 -0
- package/sails/stripe/README.md +214 -0
- package/sails/stripe/addon.json +24 -0
- package/sails/stripe/files/backend/routes/stripe.ts +154 -0
- package/sails/stripe/files/backend/schema/stripe.ts +74 -0
- package/sails/stripe/files/backend/services/stripe.ts +224 -0
- package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
- package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
- package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
- package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
- package/sails/stripe/install.ts +378 -0
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sail installer module for the keel CLI (a codai project).
|
|
3
|
+
*
|
|
4
|
+
* Handles reading sail manifests, copying files, inserting code at marker
|
|
5
|
+
* comments, installing dependencies, and running migrations.
|
|
6
|
+
*
|
|
7
|
+
* Works in two modes:
|
|
8
|
+
* 1. During project creation (installSails) — installs sails selected in
|
|
9
|
+
* the creation wizard. Sail definitions are loaded from the CLI package's
|
|
10
|
+
* bundled sails directory.
|
|
11
|
+
* 2. Post-creation (installSailByName) — installs a single sail from the
|
|
12
|
+
* CLI package into an existing project via `keel sail add <name>`.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, } from "node:fs";
|
|
15
|
+
import { join, dirname, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import chalk from "chalk";
|
|
19
|
+
import ora from "ora";
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Paths
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = dirname(__filename);
|
|
25
|
+
/** Directory where sail definitions are bundled (shipped with the npm package). */
|
|
26
|
+
function getBundledSailsDir() {
|
|
27
|
+
return join(__dirname, "..", "sails");
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
function loadManifest(sailDir) {
|
|
33
|
+
const manifestPath = join(sailDir, "addon.json");
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new Error(`Failed to parse sail manifest at ${manifestPath}: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Tracks manual steps the user needs to do when auto-insertion fails. */
|
|
42
|
+
const manualSteps = [];
|
|
43
|
+
/** Get all accumulated manual steps and clear the list. */
|
|
44
|
+
export function getManualSteps() {
|
|
45
|
+
const steps = [...manualSteps];
|
|
46
|
+
manualSteps.length = 0;
|
|
47
|
+
return steps;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Insert a code snippet directly below a marker comment in a file.
|
|
51
|
+
*
|
|
52
|
+
* Marker comments follow the pattern:
|
|
53
|
+
* // [SAIL_IMPORTS]
|
|
54
|
+
* // [SAIL_ROUTES]
|
|
55
|
+
* // [SAIL_SCHEMA]
|
|
56
|
+
* {/* [SAIL_SOCIAL_BUTTONS] * /} (JSX)
|
|
57
|
+
*
|
|
58
|
+
* The function is idempotent -- it will not insert the same code twice.
|
|
59
|
+
*
|
|
60
|
+
* If the marker is missing (user modified the file), the insertion is skipped
|
|
61
|
+
* and a manual step is recorded with the exact code the user needs to add.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Find a marker in file content using whitespace-tolerant matching.
|
|
65
|
+
*
|
|
66
|
+
* Handles variations like:
|
|
67
|
+
* // [SAIL_IMPORTS]
|
|
68
|
+
* // [SAIL_IMPORTS]
|
|
69
|
+
* //[SAIL_IMPORTS]
|
|
70
|
+
* {/* [SAIL_IMPORTS] * /} (JSX)
|
|
71
|
+
*
|
|
72
|
+
* Returns the exact string found in the file, or null if not present.
|
|
73
|
+
*/
|
|
74
|
+
function findMarker(content, marker) {
|
|
75
|
+
// First try an exact match
|
|
76
|
+
if (content.includes(marker)) {
|
|
77
|
+
return marker;
|
|
78
|
+
}
|
|
79
|
+
// Extract the marker name (e.g. "SAIL_IMPORTS" from "// [SAIL_IMPORTS]")
|
|
80
|
+
const markerNameMatch = marker.match(/\[(\w+)\]/);
|
|
81
|
+
if (!markerNameMatch) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const markerName = markerNameMatch[1];
|
|
85
|
+
// Build a flexible regex that tolerates whitespace differences
|
|
86
|
+
// Matches both JS comments (// [NAME]) and JSX comments ({/* [NAME] */})
|
|
87
|
+
const flexiblePattern = new RegExp(`(?:\\/\\/\\s*\\[${markerName}\\]|\\{\\s*\\/\\*\\s*\\[${markerName}\\]\\s*\\*\\/\\s*\\})`);
|
|
88
|
+
const match = content.match(flexiblePattern);
|
|
89
|
+
return match ? match[0] : null;
|
|
90
|
+
}
|
|
91
|
+
export function insertAtMarker(filePath, marker, code) {
|
|
92
|
+
const relativePath = filePath.replace(process.cwd() + "/", "");
|
|
93
|
+
if (!existsSync(filePath)) {
|
|
94
|
+
console.log(chalk.yellow(` ⚠ File not found: ${relativePath}`));
|
|
95
|
+
manualSteps.push(`Create file ${chalk.bold(relativePath)} and add the following code:\n\n${chalk.cyan(code)}\n`);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
let content = readFileSync(filePath, "utf-8");
|
|
99
|
+
// Idempotency: skip if already inserted
|
|
100
|
+
if (content.includes(code.trim())) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
// Use whitespace-tolerant marker matching
|
|
104
|
+
const foundMarker = findMarker(content, marker);
|
|
105
|
+
if (!foundMarker) {
|
|
106
|
+
// Marker was removed or reformatted beyond recognition
|
|
107
|
+
console.log(chalk.yellow(` ⚠ Marker "${marker}" not found in ${relativePath} — skipping auto-insert`));
|
|
108
|
+
console.log(chalk.gray(` The marker may have been removed or reformatted.`));
|
|
109
|
+
manualSteps.push(`In ${chalk.bold(relativePath)}, add the following code (near where the marker "${marker}" would be):\n\n${chalk.cyan(code)}\n`);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// Warn if the marker appears more than once — only the first occurrence will be modified
|
|
113
|
+
const markerCount = content.split(foundMarker).length - 1;
|
|
114
|
+
if (markerCount > 1) {
|
|
115
|
+
console.log(chalk.yellow(` ⚠ Marker "${marker}" appears ${markerCount} times in ${relativePath} — only the first occurrence will be modified`));
|
|
116
|
+
}
|
|
117
|
+
content = content.replace(foundMarker, `${foundMarker}\n${code}`);
|
|
118
|
+
writeFileSync(filePath, content, "utf-8");
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Install npm packages into a workspace.
|
|
123
|
+
*/
|
|
124
|
+
const SAFE_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
125
|
+
function installDeps(deps, workspace, cwd) {
|
|
126
|
+
const entries = Object.entries(deps);
|
|
127
|
+
if (entries.length === 0)
|
|
128
|
+
return;
|
|
129
|
+
for (const [name] of entries) {
|
|
130
|
+
if (!SAFE_PACKAGE_NAME.test(name)) {
|
|
131
|
+
throw new Error(`Invalid package name: ${name}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const packageList = entries.map(([name, version]) => `${name}@${version}`);
|
|
135
|
+
execFileSync("npm", ["install", ...packageList, `--workspace=${workspace}`], {
|
|
136
|
+
cwd,
|
|
137
|
+
stdio: "pipe",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Append environment variables to .env.example (and .env if it exists).
|
|
142
|
+
*/
|
|
143
|
+
function appendEnvVars(projectDir, section, vars) {
|
|
144
|
+
for (const envFile of [".env.example", ".env"]) {
|
|
145
|
+
const envPath = join(projectDir, envFile);
|
|
146
|
+
if (!existsSync(envPath))
|
|
147
|
+
continue;
|
|
148
|
+
let content = readFileSync(envPath, "utf-8");
|
|
149
|
+
const lines = [];
|
|
150
|
+
for (const [key, val] of Object.entries(vars)) {
|
|
151
|
+
if (!content.includes(key)) {
|
|
152
|
+
lines.push(`${key}=${val}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (lines.length > 0) {
|
|
156
|
+
content += `\n# ${section}\n${lines.join("\n")}\n`;
|
|
157
|
+
writeFileSync(envPath, content, "utf-8");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Sail-specific installation logic
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
function installGoogleOAuth(sailDir, projectDir) {
|
|
165
|
+
const backendDir = join(projectDir, "packages/backend");
|
|
166
|
+
const frontendDir = join(projectDir, "packages/frontend");
|
|
167
|
+
// Copy GoogleButton.tsx
|
|
168
|
+
const destDir = join(frontendDir, "src/components/auth");
|
|
169
|
+
mkdirSync(destDir, { recursive: true });
|
|
170
|
+
copyFileSync(join(sailDir, "files/GoogleButton.tsx"), join(destDir, "GoogleButton.tsx"));
|
|
171
|
+
// Modify backend auth config
|
|
172
|
+
insertAtMarker(join(backendDir, "src/auth/index.ts"), "// [SAIL_SOCIAL_PROVIDERS]", ` google: {
|
|
173
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
174
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
175
|
+
},`);
|
|
176
|
+
// Add env var validation
|
|
177
|
+
insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` GOOGLE_CLIENT_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "GOOGLE_CLIENT_ID is required in production").default(""),
|
|
178
|
+
GOOGLE_CLIENT_SECRET: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "GOOGLE_CLIENT_SECRET is required in production").default(""),`);
|
|
179
|
+
// Modify login and signup forms
|
|
180
|
+
for (const form of ["LoginForm.tsx", "SignupForm.tsx"]) {
|
|
181
|
+
const formPath = join(frontendDir, "src/components/auth", form);
|
|
182
|
+
insertAtMarker(formPath, "// [SAIL_IMPORTS]", 'import { GoogleButton } from "./GoogleButton";');
|
|
183
|
+
insertAtMarker(formPath, "{/* [SAIL_SOCIAL_BUTTONS] */}", " <GoogleButton />");
|
|
184
|
+
}
|
|
185
|
+
// Add env vars
|
|
186
|
+
appendEnvVars(projectDir, "Google OAuth", {
|
|
187
|
+
GOOGLE_CLIENT_ID: "",
|
|
188
|
+
GOOGLE_CLIENT_SECRET: "",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function installPushNotifications(sailDir, projectDir) {
|
|
192
|
+
const backendDir = join(projectDir, "packages/backend");
|
|
193
|
+
const frontendDir = join(projectDir, "packages/frontend");
|
|
194
|
+
// Copy backend files
|
|
195
|
+
const backendMappings = [
|
|
196
|
+
{ src: "backend/schema/notifications.ts", dest: "src/db/schema/notifications.ts" },
|
|
197
|
+
{ src: "backend/routes/notifications.ts", dest: "src/routes/notifications.ts" },
|
|
198
|
+
{ src: "backend/services/notifications.ts", dest: "src/services/notifications.ts" },
|
|
199
|
+
];
|
|
200
|
+
for (const m of backendMappings) {
|
|
201
|
+
const destPath = join(backendDir, m.dest);
|
|
202
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
203
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
204
|
+
}
|
|
205
|
+
// Copy frontend files
|
|
206
|
+
const frontendMappings = [
|
|
207
|
+
{ src: "frontend/hooks/usePushNotifications.ts", dest: "src/hooks/usePushNotifications.ts" },
|
|
208
|
+
{ src: "frontend/components/PushNotificationInit.tsx", dest: "src/components/PushNotificationInit.tsx" },
|
|
209
|
+
];
|
|
210
|
+
for (const m of frontendMappings) {
|
|
211
|
+
const destPath = join(frontendDir, m.dest);
|
|
212
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
213
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
214
|
+
}
|
|
215
|
+
// Modify backend schema index
|
|
216
|
+
insertAtMarker(join(backendDir, "src/db/schema/index.ts"), "// [SAIL_SCHEMA]", 'export * from "./notifications.js";');
|
|
217
|
+
// Modify backend index.ts
|
|
218
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_IMPORTS]", 'import { notificationsRouter } from "./routes/notifications.js";');
|
|
219
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_ROUTES]", 'app.use("/api/notifications", notificationsRouter);');
|
|
220
|
+
// Add env var validation
|
|
221
|
+
insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` FIREBASE_PROJECT_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "FIREBASE_PROJECT_ID is required in production").default(""),
|
|
222
|
+
FIREBASE_PRIVATE_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "FIREBASE_PRIVATE_KEY is required in production").default(""),
|
|
223
|
+
FIREBASE_CLIENT_EMAIL: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "FIREBASE_CLIENT_EMAIL is required in production").default(""),`);
|
|
224
|
+
// Modify Layout.tsx to include PushNotificationInit
|
|
225
|
+
const layoutPath = join(frontendDir, "src/components/layout/Layout.tsx");
|
|
226
|
+
if (existsSync(layoutPath)) {
|
|
227
|
+
let content = readFileSync(layoutPath, "utf-8");
|
|
228
|
+
if (!content.includes("PushNotificationInit")) {
|
|
229
|
+
const importLine = 'import { PushNotificationInit } from "@/components/PushNotificationInit.js";';
|
|
230
|
+
const lastImport = content.lastIndexOf("import ");
|
|
231
|
+
const importLineEnd = content.indexOf("\n", lastImport);
|
|
232
|
+
content =
|
|
233
|
+
content.slice(0, importLineEnd + 1) +
|
|
234
|
+
importLine + "\n" +
|
|
235
|
+
content.slice(importLineEnd + 1);
|
|
236
|
+
content = content.replace('<div className="flex min-h-screen flex-col bg-keel-navy">', '<div className="flex min-h-screen flex-col bg-keel-navy">\n <PushNotificationInit />');
|
|
237
|
+
writeFileSync(layoutPath, content, "utf-8");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Install dependencies
|
|
241
|
+
const manifest = loadManifest(sailDir);
|
|
242
|
+
installDeps(manifest.dependencies.backend, "packages/backend", projectDir);
|
|
243
|
+
installDeps(manifest.dependencies.frontend, "packages/frontend", projectDir);
|
|
244
|
+
// Generate migrations
|
|
245
|
+
try {
|
|
246
|
+
execFileSync("npx", ["drizzle-kit", "generate"], {
|
|
247
|
+
cwd: backendDir,
|
|
248
|
+
stdio: "pipe",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Migration generation may fail if drizzle-kit is not yet configured
|
|
253
|
+
}
|
|
254
|
+
// Add env vars
|
|
255
|
+
appendEnvVars(projectDir, "Push Notifications (Firebase)", {
|
|
256
|
+
FIREBASE_PROJECT_ID: "",
|
|
257
|
+
FIREBASE_PRIVATE_KEY: "",
|
|
258
|
+
FIREBASE_CLIENT_EMAIL: "",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function installAnalytics(sailDir, projectDir) {
|
|
262
|
+
const frontendDir = join(projectDir, "packages/frontend");
|
|
263
|
+
// Copy frontend files
|
|
264
|
+
const frontendMappings = [
|
|
265
|
+
{ src: "frontend/lib/analytics.ts", dest: "src/lib/analytics.ts" },
|
|
266
|
+
{ src: "frontend/hooks/useAnalytics.ts", dest: "src/hooks/useAnalytics.ts" },
|
|
267
|
+
{ src: "frontend/components/AnalyticsProvider.tsx", dest: "src/components/AnalyticsProvider.tsx" },
|
|
268
|
+
];
|
|
269
|
+
for (const m of frontendMappings) {
|
|
270
|
+
const destPath = join(frontendDir, m.dest);
|
|
271
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
272
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
273
|
+
}
|
|
274
|
+
// Modify App.tsx to wrap with AnalyticsProvider
|
|
275
|
+
const appPath = join(frontendDir, "src/App.tsx");
|
|
276
|
+
if (existsSync(appPath)) {
|
|
277
|
+
let content = readFileSync(appPath, "utf-8");
|
|
278
|
+
if (!content.includes("AnalyticsProvider")) {
|
|
279
|
+
const importLine = 'import { AnalyticsProvider } from "./components/AnalyticsProvider.js";';
|
|
280
|
+
const lastImport = content.lastIndexOf("import ");
|
|
281
|
+
const importLineEnd = content.indexOf("\n", lastImport);
|
|
282
|
+
content =
|
|
283
|
+
content.slice(0, importLineEnd + 1) +
|
|
284
|
+
importLine + "\n" +
|
|
285
|
+
content.slice(importLineEnd + 1);
|
|
286
|
+
content = content.replace("<AppRouter />", "<AnalyticsProvider>\n <AppRouter />\n </AnalyticsProvider>");
|
|
287
|
+
writeFileSync(appPath, content, "utf-8");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Install dependencies
|
|
291
|
+
const manifest = loadManifest(sailDir);
|
|
292
|
+
installDeps(manifest.dependencies.frontend, "packages/frontend", projectDir);
|
|
293
|
+
// Add env vars
|
|
294
|
+
appendEnvVars(projectDir, "PostHog Analytics", {
|
|
295
|
+
VITE_POSTHOG_KEY: "",
|
|
296
|
+
VITE_POSTHOG_HOST: "https://us.i.posthog.com",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
function installStripe(sailDir, projectDir) {
|
|
300
|
+
const backendDir = join(projectDir, "packages/backend");
|
|
301
|
+
const frontendDir = join(projectDir, "packages/frontend");
|
|
302
|
+
// Copy backend files
|
|
303
|
+
const backendMappings = [
|
|
304
|
+
{ src: "backend/schema/stripe.ts", dest: "src/db/schema/stripe.ts" },
|
|
305
|
+
{ src: "backend/routes/stripe.ts", dest: "src/routes/stripe.ts" },
|
|
306
|
+
{ src: "backend/services/stripe.ts", dest: "src/services/stripe.ts" },
|
|
307
|
+
];
|
|
308
|
+
for (const m of backendMappings) {
|
|
309
|
+
const destPath = join(backendDir, m.dest);
|
|
310
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
311
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
312
|
+
}
|
|
313
|
+
// Copy frontend files
|
|
314
|
+
const frontendMappings = [
|
|
315
|
+
{ src: "frontend/pages/Pricing.tsx", dest: "src/pages/Pricing.tsx" },
|
|
316
|
+
{ src: "frontend/pages/Checkout.tsx", dest: "src/pages/Checkout.tsx" },
|
|
317
|
+
{
|
|
318
|
+
src: "frontend/components/SubscriptionStatus.tsx",
|
|
319
|
+
dest: "src/components/stripe/SubscriptionStatus.tsx",
|
|
320
|
+
},
|
|
321
|
+
{ src: "frontend/hooks/useSubscription.ts", dest: "src/hooks/useSubscription.ts" },
|
|
322
|
+
];
|
|
323
|
+
for (const m of frontendMappings) {
|
|
324
|
+
const destPath = join(frontendDir, m.dest);
|
|
325
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
326
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
327
|
+
}
|
|
328
|
+
// Modify backend schema index
|
|
329
|
+
insertAtMarker(join(backendDir, "src/db/schema/index.ts"), "// [SAIL_SCHEMA]", 'export * from "./stripe";');
|
|
330
|
+
// Modify backend index.ts
|
|
331
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_IMPORTS]", 'import { stripeRouter } from "./routes/stripe";');
|
|
332
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_ROUTES]", 'app.use("/api/stripe", stripeRouter);');
|
|
333
|
+
// Stripe webhook needs the raw body — insert express.raw() BEFORE express.json()
|
|
334
|
+
const indexPath = join(backendDir, "src/index.ts");
|
|
335
|
+
if (existsSync(indexPath)) {
|
|
336
|
+
let indexContent = readFileSync(indexPath, "utf-8");
|
|
337
|
+
const rawMiddleware = 'app.use("/api/stripe/webhook", express.raw({ type: "application/json" }));';
|
|
338
|
+
if (!indexContent.includes(rawMiddleware)) {
|
|
339
|
+
indexContent = indexContent.replace("app.use(express.json());", `// Raw body for Stripe webhook signature verification (must be before express.json())\n${rawMiddleware}\n\napp.use(express.json());`);
|
|
340
|
+
writeFileSync(indexPath, indexContent, "utf-8");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Add env var validation
|
|
344
|
+
insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` STRIPE_SECRET_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "STRIPE_SECRET_KEY is required in production").default(""),
|
|
345
|
+
STRIPE_PUBLISHABLE_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "STRIPE_PUBLISHABLE_KEY is required in production").default(""),
|
|
346
|
+
STRIPE_WEBHOOK_SECRET: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "STRIPE_WEBHOOK_SECRET is required in production").default(""),`);
|
|
347
|
+
// Modify frontend router
|
|
348
|
+
insertAtMarker(join(frontendDir, "src/router.tsx"), "// [SAIL_IMPORTS]", `import { PricingPage } from "./pages/Pricing";
|
|
349
|
+
import { CheckoutPage } from "./pages/Checkout";`);
|
|
350
|
+
insertAtMarker(join(frontendDir, "src/router.tsx"), "// [SAIL_ROUTES]", ` {
|
|
351
|
+
path: "/pricing",
|
|
352
|
+
element: <PricingPage />,
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
path: "/checkout/success",
|
|
356
|
+
element: <CheckoutPage status="success" />,
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
path: "/checkout/cancel",
|
|
360
|
+
element: <CheckoutPage status="cancel" />,
|
|
361
|
+
},`);
|
|
362
|
+
// Install dependencies
|
|
363
|
+
const manifest = loadManifest(sailDir);
|
|
364
|
+
installDeps(manifest.dependencies.backend, "packages/backend", projectDir);
|
|
365
|
+
installDeps(manifest.dependencies.frontend, "packages/frontend", projectDir);
|
|
366
|
+
// Generate migrations
|
|
367
|
+
try {
|
|
368
|
+
execFileSync("npx", ["drizzle-kit", "generate"], {
|
|
369
|
+
cwd: backendDir,
|
|
370
|
+
stdio: "pipe",
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// Migration generation may fail if drizzle-kit is not yet configured
|
|
375
|
+
}
|
|
376
|
+
// Add env vars
|
|
377
|
+
appendEnvVars(projectDir, "Stripe Payments", {
|
|
378
|
+
STRIPE_SECRET_KEY: "",
|
|
379
|
+
STRIPE_PUBLISHABLE_KEY: "",
|
|
380
|
+
STRIPE_WEBHOOK_SECRET: "",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
function installAdminDashboard(sailDir, projectDir) {
|
|
384
|
+
const backendDir = join(projectDir, "packages/backend");
|
|
385
|
+
const frontendDir = join(projectDir, "packages/frontend");
|
|
386
|
+
// Copy backend files
|
|
387
|
+
const backendMappings = [
|
|
388
|
+
{ src: "backend/middleware/admin.ts", dest: "src/middleware/admin.ts" },
|
|
389
|
+
{ src: "backend/routes/admin.ts", dest: "src/routes/admin.ts" },
|
|
390
|
+
];
|
|
391
|
+
for (const m of backendMappings) {
|
|
392
|
+
const destPath = join(backendDir, m.dest);
|
|
393
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
394
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
395
|
+
}
|
|
396
|
+
// Copy frontend files
|
|
397
|
+
const frontendMappings = [
|
|
398
|
+
{ src: "frontend/pages/admin/Dashboard.tsx", dest: "src/pages/admin/Dashboard.tsx" },
|
|
399
|
+
{ src: "frontend/pages/admin/UserDetail.tsx", dest: "src/pages/admin/UserDetail.tsx" },
|
|
400
|
+
{ src: "frontend/components/admin/StatsCard.tsx", dest: "src/components/admin/StatsCard.tsx" },
|
|
401
|
+
{ src: "frontend/components/admin/UsersTable.tsx", dest: "src/components/admin/UsersTable.tsx" },
|
|
402
|
+
{ src: "frontend/hooks/useAdmin.ts", dest: "src/hooks/useAdmin.ts" },
|
|
403
|
+
];
|
|
404
|
+
for (const m of frontendMappings) {
|
|
405
|
+
const destPath = join(frontendDir, m.dest);
|
|
406
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
407
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
408
|
+
}
|
|
409
|
+
// Modify backend index.ts
|
|
410
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_IMPORTS]", 'import { adminRouter } from "./routes/admin.js";');
|
|
411
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_ROUTES]", 'app.use("/api/admin", adminRouter);');
|
|
412
|
+
// Add env var validation
|
|
413
|
+
insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ' ADMIN_EMAILS: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "ADMIN_EMAILS is required in production").default(""),');
|
|
414
|
+
// Add frontend routes
|
|
415
|
+
insertAtMarker(join(frontendDir, "src/router.tsx"), "{/* [SAIL_ROUTES] */}", ` <Route path="/admin" element={<ProtectedRoute><AdminDashboard /></ProtectedRoute>} />
|
|
416
|
+
<Route path="/admin/users/:id" element={<ProtectedRoute><AdminUserDetail /></ProtectedRoute>} />`);
|
|
417
|
+
// Install dependencies
|
|
418
|
+
const manifest = loadManifest(sailDir);
|
|
419
|
+
installDeps(manifest.dependencies.frontend, "packages/frontend", projectDir);
|
|
420
|
+
// Add env vars
|
|
421
|
+
appendEnvVars(projectDir, "Admin Dashboard", {
|
|
422
|
+
ADMIN_EMAILS: "",
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function installI18n(sailDir, projectDir) {
|
|
426
|
+
const frontendDir = join(projectDir, "packages/frontend");
|
|
427
|
+
// Copy frontend files
|
|
428
|
+
const frontendMappings = [
|
|
429
|
+
{ src: "frontend/lib/i18n.ts", dest: "src/lib/i18n.ts" },
|
|
430
|
+
{ src: "frontend/hooks/useLanguage.ts", dest: "src/hooks/useLanguage.ts" },
|
|
431
|
+
{ src: "frontend/components/LanguageSwitcher.tsx", dest: "src/components/LanguageSwitcher.tsx" },
|
|
432
|
+
{ src: "frontend/locales/en/common.json", dest: "src/locales/en/common.json" },
|
|
433
|
+
{ src: "frontend/locales/de/common.json", dest: "src/locales/de/common.json" },
|
|
434
|
+
];
|
|
435
|
+
for (const m of frontendMappings) {
|
|
436
|
+
const destPath = join(frontendDir, m.dest);
|
|
437
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
438
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
439
|
+
}
|
|
440
|
+
// Modify main.tsx to import i18n
|
|
441
|
+
const mainPath = join(frontendDir, "src/main.tsx");
|
|
442
|
+
if (existsSync(mainPath)) {
|
|
443
|
+
let content = readFileSync(mainPath, "utf-8");
|
|
444
|
+
if (!content.includes("./lib/i18n")) {
|
|
445
|
+
content = 'import "./lib/i18n.js";\n' + content;
|
|
446
|
+
writeFileSync(mainPath, content, "utf-8");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Install dependencies
|
|
450
|
+
const manifest = loadManifest(sailDir);
|
|
451
|
+
installDeps(manifest.dependencies.frontend, "packages/frontend", projectDir);
|
|
452
|
+
}
|
|
453
|
+
function installRateLimiting(sailDir, projectDir) {
|
|
454
|
+
const backendDir = join(projectDir, "packages/backend");
|
|
455
|
+
// Copy backend files
|
|
456
|
+
const backendMappings = [
|
|
457
|
+
{ src: "backend/middleware/rate-limit.ts", dest: "src/middleware/rate-limit.ts" },
|
|
458
|
+
{ src: "backend/middleware/rate-limit-store.ts", dest: "src/middleware/rate-limit-store.ts" },
|
|
459
|
+
];
|
|
460
|
+
for (const m of backendMappings) {
|
|
461
|
+
const destPath = join(backendDir, m.dest);
|
|
462
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
463
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
464
|
+
}
|
|
465
|
+
// Add rate limiter import and apply globally
|
|
466
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_IMPORTS]", 'import { apiLimiter, authLimiter } from "./middleware/rate-limit.js";');
|
|
467
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_ROUTES]", 'app.use("/api", apiLimiter);');
|
|
468
|
+
}
|
|
469
|
+
function installGdpr(sailDir, projectDir) {
|
|
470
|
+
const backendDir = join(projectDir, "packages/backend");
|
|
471
|
+
const frontendDir = join(projectDir, "packages/frontend");
|
|
472
|
+
// Copy backend files
|
|
473
|
+
const backendMappings = [
|
|
474
|
+
{ src: "backend/services/gdpr.ts", dest: "src/services/gdpr.ts" },
|
|
475
|
+
{ src: "backend/routes/gdpr.ts", dest: "src/routes/gdpr.ts" },
|
|
476
|
+
];
|
|
477
|
+
for (const m of backendMappings) {
|
|
478
|
+
const destPath = join(backendDir, m.dest);
|
|
479
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
480
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
481
|
+
}
|
|
482
|
+
// Copy frontend files
|
|
483
|
+
const frontendMappings = [
|
|
484
|
+
{ src: "frontend/components/gdpr/DataExportButton.tsx", dest: "src/components/gdpr/DataExportButton.tsx" },
|
|
485
|
+
{ src: "frontend/components/gdpr/AccountDeletionRequest.tsx", dest: "src/components/gdpr/AccountDeletionRequest.tsx" },
|
|
486
|
+
{ src: "frontend/components/auth/ConsentCheckboxes.tsx", dest: "src/components/auth/ConsentCheckboxes.tsx" },
|
|
487
|
+
{ src: "frontend/pages/PrivacyPolicy.tsx", dest: "src/pages/PrivacyPolicy.tsx" },
|
|
488
|
+
];
|
|
489
|
+
for (const m of frontendMappings) {
|
|
490
|
+
const destPath = join(frontendDir, m.dest);
|
|
491
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
492
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
493
|
+
}
|
|
494
|
+
// Add GDPR schema tables to schema.ts (inline, since there is no separate schema file)
|
|
495
|
+
const schemaPath = join(backendDir, "src/db/schema.ts");
|
|
496
|
+
if (existsSync(schemaPath)) {
|
|
497
|
+
let schemaContent = readFileSync(schemaPath, "utf-8");
|
|
498
|
+
// Ensure varchar import is present
|
|
499
|
+
if (!schemaContent.includes("varchar")) {
|
|
500
|
+
schemaContent = schemaContent.replace(/import\s*\{([^}]*)\}\s*from\s*"drizzle-orm\/pg-core"/, (match, imports) => {
|
|
501
|
+
const trimmed = imports.trim().replace(/,\s*$/, "");
|
|
502
|
+
return `import { ${trimmed}, varchar } from "drizzle-orm/pg-core"`;
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
// Insert GDPR table definitions at the SAIL_SCHEMA marker
|
|
506
|
+
if (schemaContent.includes("// [SAIL_SCHEMA]") && !schemaContent.includes("consentRecords")) {
|
|
507
|
+
const gdprSchema = `
|
|
508
|
+
export const consentRecords = pgTable("consent_records", {
|
|
509
|
+
id: text("id")
|
|
510
|
+
.primaryKey()
|
|
511
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
512
|
+
userId: text("user_id")
|
|
513
|
+
.notNull()
|
|
514
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
515
|
+
consentType: varchar("consent_type", { length: 50 }).notNull(),
|
|
516
|
+
granted: boolean("granted").notNull(),
|
|
517
|
+
version: varchar("version", { length: 20 }).notNull(),
|
|
518
|
+
ipAddress: text("ip_address"),
|
|
519
|
+
userAgent: text("user_agent"),
|
|
520
|
+
grantedAt: timestamp("granted_at").defaultNow().notNull(),
|
|
521
|
+
revokedAt: timestamp("revoked_at"),
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
export const deletionRequests = pgTable("deletion_requests", {
|
|
525
|
+
id: text("id")
|
|
526
|
+
.primaryKey()
|
|
527
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
528
|
+
userId: text("user_id")
|
|
529
|
+
.notNull()
|
|
530
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
531
|
+
status: varchar("status", { length: 20 }).default("pending").notNull(),
|
|
532
|
+
reason: text("reason"),
|
|
533
|
+
requestedAt: timestamp("requested_at").defaultNow().notNull(),
|
|
534
|
+
scheduledDeletionAt: timestamp("scheduled_deletion_at").notNull(),
|
|
535
|
+
cancelledAt: timestamp("cancelled_at"),
|
|
536
|
+
completedAt: timestamp("completed_at"),
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
export const consentRecordsRelations = relations(consentRecords, ({ one }) => ({
|
|
540
|
+
user: one(users, { fields: [consentRecords.userId], references: [users.id] }),
|
|
541
|
+
}));
|
|
542
|
+
|
|
543
|
+
export const deletionRequestsRelations = relations(deletionRequests, ({ one }) => ({
|
|
544
|
+
user: one(users, { fields: [deletionRequests.userId], references: [users.id] }),
|
|
545
|
+
}));
|
|
546
|
+
`;
|
|
547
|
+
schemaContent = schemaContent.replace("// [SAIL_SCHEMA]", `// [SAIL_SCHEMA]\n${gdprSchema}`);
|
|
548
|
+
}
|
|
549
|
+
// Add GDPR relations to usersRelations
|
|
550
|
+
if (schemaContent.includes("usersRelations") && !schemaContent.includes("consentRecords: many(consentRecords)")) {
|
|
551
|
+
schemaContent = schemaContent.replace(/export const usersRelations = relations\(users, \(\{ many \}\) => \(\{/, `export const usersRelations = relations(users, ({ many }) => ({\n consentRecords: many(consentRecords),\n deletionRequests: many(deletionRequests),`);
|
|
552
|
+
}
|
|
553
|
+
writeFileSync(schemaPath, schemaContent, "utf-8");
|
|
554
|
+
}
|
|
555
|
+
// Modify backend index.ts
|
|
556
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_IMPORTS]", 'import gdprRoutes from "./routes/gdpr.js";');
|
|
557
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_ROUTES]", 'app.use("/api/gdpr", gdprRoutes);');
|
|
558
|
+
// Add env var validation
|
|
559
|
+
insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ' DELETION_CRON_SECRET: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "DELETION_CRON_SECRET is required in production").default("dev-cron-secret"),');
|
|
560
|
+
// Add privacy policy route to frontend router
|
|
561
|
+
insertAtMarker(join(frontendDir, "src/router.tsx"), "// [SAIL_IMPORTS]", 'import PrivacyPolicy from "./pages/PrivacyPolicy";');
|
|
562
|
+
insertAtMarker(join(frontendDir, "src/router.tsx"), "{/* [SAIL_ROUTES] */}", ' <Route path="/privacy-policy" element={<PrivacyPolicy />} />');
|
|
563
|
+
// Modify SignupForm to include ConsentCheckboxes
|
|
564
|
+
const signupPath = join(frontendDir, "src/components/auth/SignupForm.tsx");
|
|
565
|
+
if (existsSync(signupPath)) {
|
|
566
|
+
let signupContent = readFileSync(signupPath, "utf-8");
|
|
567
|
+
if (!signupContent.includes("ConsentCheckboxes")) {
|
|
568
|
+
signupContent = signupContent.replace('import { useAuth } from "@/hooks/useAuth";', 'import { useAuth } from "@/hooks/useAuth";\nimport { apiPost } from "@/lib/api";\nimport ConsentCheckboxes, { type ConsentState } from "./ConsentCheckboxes";');
|
|
569
|
+
signupContent = signupContent.replace(' const [confirmPassword, setConfirmPassword] = useState("");', ' const [confirmPassword, setConfirmPassword] = useState("");\n const [consent, setConsent] = useState<ConsentState>({\n privacyPolicy: false,\n termsOfService: false,\n marketingEmails: false,\n analytics: false,\n });');
|
|
570
|
+
signupContent = signupContent.replace(" setIsSubmitting(true);\n\n try {\n await signup(email, password, name);\n\n setSuccess(true);", ` if (!consent.privacyPolicy || !consent.termsOfService) {
|
|
571
|
+
setError("You must accept the Privacy Policy and Terms of Service.");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
setIsSubmitting(true);
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
await signup(email, password, name);
|
|
579
|
+
|
|
580
|
+
// Record consent after successful signup
|
|
581
|
+
try {
|
|
582
|
+
await apiPost("/api/gdpr/consent", {
|
|
583
|
+
privacyPolicy: consent.privacyPolicy,
|
|
584
|
+
termsOfService: consent.termsOfService,
|
|
585
|
+
marketingEmails: consent.marketingEmails,
|
|
586
|
+
analytics: consent.analytics,
|
|
587
|
+
});
|
|
588
|
+
} catch {
|
|
589
|
+
// Non-critical: consent recording failure shouldn't block signup
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
setSuccess(true);`);
|
|
593
|
+
signupContent = signupContent.replace(" <button\n type=\"submit\"", " <ConsentCheckboxes value={consent} onChange={setConsent} />\n\n <button\n type=\"submit\"");
|
|
594
|
+
writeFileSync(signupPath, signupContent, "utf-8");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Modify AccountSettings to include GDPR section
|
|
598
|
+
const settingsPath = join(frontendDir, "src/components/profile/AccountSettings.tsx");
|
|
599
|
+
if (existsSync(settingsPath)) {
|
|
600
|
+
let settingsContent = readFileSync(settingsPath, "utf-8");
|
|
601
|
+
if (!settingsContent.includes("DataExportButton")) {
|
|
602
|
+
settingsContent = settingsContent.replace('import { apiGet } from "@/lib/api";', 'import { apiGet, apiPost } from "@/lib/api";\nimport DataExportButton from "../gdpr/DataExportButton";\nimport AccountDeletionRequest from "../gdpr/AccountDeletionRequest";');
|
|
603
|
+
settingsContent = settingsContent.replace("interface Session {", `interface ConsentSettings {
|
|
604
|
+
marketingEmails: boolean;
|
|
605
|
+
analytics: boolean;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
interface Session {`);
|
|
609
|
+
settingsContent = settingsContent.replace(" const [sessions, setSessions] = useState<Session[]>([]);", ` const [consent, setConsent] = useState<ConsentSettings>({
|
|
610
|
+
marketingEmails: false,
|
|
611
|
+
analytics: false,
|
|
612
|
+
});
|
|
613
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
614
|
+
const [consentLoading, setConsentLoading] = useState(true);
|
|
615
|
+
const [consentSaving, setConsentSaving] = useState(false);`);
|
|
616
|
+
settingsContent = settingsContent.replace(` async function loadSettings() {
|
|
617
|
+
try {
|
|
618
|
+
const sessionsData = await apiGet<Session[]>("/api/auth/sessions");
|
|
619
|
+
setSessions(sessionsData);
|
|
620
|
+
} catch {
|
|
621
|
+
// Settings may not exist yet
|
|
622
|
+
}
|
|
623
|
+
}`, ` async function loadSettings() {
|
|
624
|
+
try {
|
|
625
|
+
const [consentData, sessionsData] = await Promise.all([
|
|
626
|
+
apiGet<ConsentSettings>("/api/gdpr/consent"),
|
|
627
|
+
apiGet<Session[]>("/api/auth/sessions"),
|
|
628
|
+
]);
|
|
629
|
+
setConsent(consentData);
|
|
630
|
+
setSessions(sessionsData);
|
|
631
|
+
} catch {
|
|
632
|
+
// Settings may not exist yet
|
|
633
|
+
} finally {
|
|
634
|
+
setConsentLoading(false);
|
|
635
|
+
}
|
|
636
|
+
}`);
|
|
637
|
+
settingsContent = settingsContent.replace(" return (", ` const handleConsentChange = async (
|
|
638
|
+
field: keyof ConsentSettings,
|
|
639
|
+
value: boolean,
|
|
640
|
+
) => {
|
|
641
|
+
const updated = { ...consent, [field]: value };
|
|
642
|
+
setConsent(updated);
|
|
643
|
+
setConsentSaving(true);
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
await apiPost("/api/gdpr/consent", updated);
|
|
647
|
+
} catch {
|
|
648
|
+
// Revert on error
|
|
649
|
+
setConsent(consent);
|
|
650
|
+
} finally {
|
|
651
|
+
setConsentSaving(false);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
return (`);
|
|
656
|
+
writeFileSync(settingsPath, settingsContent, "utf-8");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Generate migrations
|
|
660
|
+
try {
|
|
661
|
+
execFileSync("npx", ["drizzle-kit", "generate"], {
|
|
662
|
+
cwd: backendDir,
|
|
663
|
+
stdio: "pipe",
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
// Migration generation may fail if drizzle-kit is not yet configured
|
|
668
|
+
}
|
|
669
|
+
// Add env vars
|
|
670
|
+
appendEnvVars(projectDir, "GDPR", {
|
|
671
|
+
DELETION_CRON_SECRET: "",
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
function installR2Storage(sailDir, projectDir) {
|
|
675
|
+
const backendDir = join(projectDir, "packages/backend");
|
|
676
|
+
const frontendDir = join(projectDir, "packages/frontend");
|
|
677
|
+
// Copy backend files
|
|
678
|
+
const destStoragePath = join(backendDir, "src/services/storage.ts");
|
|
679
|
+
mkdirSync(dirname(destStoragePath), { recursive: true });
|
|
680
|
+
copyFileSync(join(sailDir, "files/backend/services/storage.ts"), destStoragePath);
|
|
681
|
+
// Copy frontend files
|
|
682
|
+
const destUploadPath = join(frontendDir, "src/components/profile/ProfilePictureUpload.tsx");
|
|
683
|
+
mkdirSync(dirname(destUploadPath), { recursive: true });
|
|
684
|
+
copyFileSync(join(sailDir, "files/frontend/components/ProfilePictureUpload.tsx"), destUploadPath);
|
|
685
|
+
// Add R2 env var validation
|
|
686
|
+
insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` R2_ACCOUNT_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_ACCOUNT_ID is required in production").default(""),
|
|
687
|
+
R2_ACCESS_KEY_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_ACCESS_KEY_ID is required in production").default(""),
|
|
688
|
+
R2_SECRET_ACCESS_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_SECRET_ACCESS_KEY is required in production").default(""),
|
|
689
|
+
R2_BUCKET_NAME: z.string().default("avatars"),
|
|
690
|
+
R2_PUBLIC_URL: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "R2_PUBLIC_URL is required in production").default(""),`);
|
|
691
|
+
// Add avatar routes to profile.ts
|
|
692
|
+
const profilePath = join(backendDir, "src/routes/profile.ts");
|
|
693
|
+
if (existsSync(profilePath)) {
|
|
694
|
+
let profileContent = readFileSync(profilePath, "utf-8");
|
|
695
|
+
// Add storage import if not present
|
|
696
|
+
if (!profileContent.includes("storage")) {
|
|
697
|
+
profileContent = profileContent.replace('import { db } from "../db/index.js";', 'import { db } from "../db/index.js";\nimport { generateUploadUrl, deleteObject } from "../services/storage.js";');
|
|
698
|
+
}
|
|
699
|
+
// Add avatar routes if not present
|
|
700
|
+
if (!profileContent.includes("/avatar/upload-url")) {
|
|
701
|
+
const avatarRoutes = `
|
|
702
|
+
// POST /avatar/upload-url — generate presigned upload URL
|
|
703
|
+
router.post("/avatar/upload-url", async (req, res) => {
|
|
704
|
+
const { fileType } = req.body as { fileType?: string };
|
|
705
|
+
|
|
706
|
+
if (!fileType || typeof fileType !== "string") {
|
|
707
|
+
res.status(400).json({ error: "fileType is required" });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const result = await generateUploadUrl(req.user!.id, fileType);
|
|
712
|
+
res.json(result);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// DELETE /avatar — delete current avatar
|
|
716
|
+
router.delete("/avatar", async (req, res) => {
|
|
717
|
+
const user = req.user!;
|
|
718
|
+
|
|
719
|
+
if (user.image) {
|
|
720
|
+
try {
|
|
721
|
+
// Extract key from the image URL or stored key
|
|
722
|
+
await deleteObject(user.image);
|
|
723
|
+
} catch {
|
|
724
|
+
// Continue even if R2 deletion fails
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const [updated] = await db
|
|
729
|
+
.update(users)
|
|
730
|
+
.set({ image: null, updatedAt: new Date() })
|
|
731
|
+
.where(eq(users.id, user.id))
|
|
732
|
+
.returning();
|
|
733
|
+
|
|
734
|
+
res.json({ user: updated });
|
|
735
|
+
});`;
|
|
736
|
+
profileContent = profileContent.replace("export default router;", `${avatarRoutes}\n\nexport default router;`);
|
|
737
|
+
}
|
|
738
|
+
writeFileSync(profilePath, profileContent, "utf-8");
|
|
739
|
+
}
|
|
740
|
+
// Add ProfilePictureUpload to ProfilePage.tsx
|
|
741
|
+
const profilePagePath = join(frontendDir, "src/components/profile/ProfilePage.tsx");
|
|
742
|
+
if (existsSync(profilePagePath)) {
|
|
743
|
+
let pageContent = readFileSync(profilePagePath, "utf-8");
|
|
744
|
+
if (!pageContent.includes("ProfilePictureUpload")) {
|
|
745
|
+
pageContent = pageContent.replace('import { apiPatch } from "@/lib/api";', 'import { apiPatch } from "@/lib/api";\nimport ProfilePictureUpload from "./ProfilePictureUpload";');
|
|
746
|
+
pageContent = pageContent.replace('<div className="flex flex-col items-start gap-6 sm:flex-row">', '<div className="flex flex-col items-start gap-6 sm:flex-row">\n <ProfilePictureUpload />');
|
|
747
|
+
}
|
|
748
|
+
writeFileSync(profilePagePath, pageContent, "utf-8");
|
|
749
|
+
}
|
|
750
|
+
// Install dependencies
|
|
751
|
+
const manifest = loadManifest(sailDir);
|
|
752
|
+
installDeps(manifest.dependencies.backend, "packages/backend", projectDir);
|
|
753
|
+
installDeps(manifest.dependencies.frontend, "packages/frontend", projectDir);
|
|
754
|
+
// Add env vars
|
|
755
|
+
appendEnvVars(projectDir, "Cloudflare R2 Storage", {
|
|
756
|
+
R2_ACCOUNT_ID: "",
|
|
757
|
+
R2_ACCESS_KEY_ID: "",
|
|
758
|
+
R2_SECRET_ACCESS_KEY: "",
|
|
759
|
+
R2_BUCKET_NAME: "avatars",
|
|
760
|
+
R2_PUBLIC_URL: "",
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
function installFileUploads(sailDir, projectDir) {
|
|
764
|
+
const backendDir = join(projectDir, "packages/backend");
|
|
765
|
+
const frontendDir = join(projectDir, "packages/frontend");
|
|
766
|
+
// Copy backend files
|
|
767
|
+
const backendMappings = [
|
|
768
|
+
{ src: "backend/services/file-storage.ts", dest: "src/services/file-storage.ts" },
|
|
769
|
+
{ src: "backend/routes/files.ts", dest: "src/routes/files.ts" },
|
|
770
|
+
{ src: "backend/schema/files.ts", dest: "src/db/schema/files.ts" },
|
|
771
|
+
];
|
|
772
|
+
for (const m of backendMappings) {
|
|
773
|
+
const destPath = join(backendDir, m.dest);
|
|
774
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
775
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
776
|
+
}
|
|
777
|
+
// Copy frontend files
|
|
778
|
+
const frontendMappings = [
|
|
779
|
+
{ src: "frontend/hooks/useFileUpload.ts", dest: "src/hooks/useFileUpload.ts" },
|
|
780
|
+
{ src: "frontend/hooks/useFiles.ts", dest: "src/hooks/useFiles.ts" },
|
|
781
|
+
{ src: "frontend/components/FileUploadButton.tsx", dest: "src/components/files/FileUploadButton.tsx" },
|
|
782
|
+
{ src: "frontend/components/FileList.tsx", dest: "src/components/files/FileList.tsx" },
|
|
783
|
+
{ src: "frontend/pages/Files.tsx", dest: "src/pages/Files.tsx" },
|
|
784
|
+
];
|
|
785
|
+
for (const m of frontendMappings) {
|
|
786
|
+
const destPath = join(frontendDir, m.dest);
|
|
787
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
788
|
+
copyFileSync(join(sailDir, "files", m.src), destPath);
|
|
789
|
+
}
|
|
790
|
+
// Modify backend schema index
|
|
791
|
+
insertAtMarker(join(backendDir, "src/db/schema/index.ts"), "// [SAIL_SCHEMA]", 'export * from "./files.js";');
|
|
792
|
+
// Modify backend index.ts
|
|
793
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_IMPORTS]", 'import { filesRouter } from "./routes/files.js";');
|
|
794
|
+
insertAtMarker(join(backendDir, "src/index.ts"), "// [SAIL_ROUTES]", 'app.use("/api/files", filesRouter);');
|
|
795
|
+
// Add env var validation
|
|
796
|
+
insertAtMarker(join(backendDir, "src/env.ts"), "// [SAIL_ENV_VARS]", ` S3_ENDPOINT: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_ENDPOINT is required in production").default(""),
|
|
797
|
+
S3_ACCESS_KEY_ID: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_ACCESS_KEY_ID is required in production").default(""),
|
|
798
|
+
S3_SECRET_ACCESS_KEY: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_SECRET_ACCESS_KEY is required in production").default(""),
|
|
799
|
+
S3_BUCKET_NAME: z.string().default("uploads"),
|
|
800
|
+
S3_PUBLIC_URL: z.string().min(process.env.NODE_ENV === "production" ? 1 : 0, "S3_PUBLIC_URL is required in production").default(""),
|
|
801
|
+
S3_REGION: z.string().default("auto"),`);
|
|
802
|
+
// Add frontend route
|
|
803
|
+
insertAtMarker(join(frontendDir, "src/router.tsx"), "{/* [SAIL_ROUTES] */}", ' <Route path="/files" element={<ProtectedRoute><FilesPage /></ProtectedRoute>} />');
|
|
804
|
+
// Install dependencies
|
|
805
|
+
const manifest = loadManifest(sailDir);
|
|
806
|
+
installDeps(manifest.dependencies.backend, "packages/backend", projectDir);
|
|
807
|
+
// Generate migrations
|
|
808
|
+
try {
|
|
809
|
+
execFileSync("npx", ["drizzle-kit", "generate"], {
|
|
810
|
+
cwd: backendDir,
|
|
811
|
+
stdio: "pipe",
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
// Migration generation may fail if drizzle-kit is not yet configured
|
|
816
|
+
}
|
|
817
|
+
// Add env vars
|
|
818
|
+
appendEnvVars(projectDir, "File Uploads (S3-compatible)", {
|
|
819
|
+
S3_ENDPOINT: "",
|
|
820
|
+
S3_ACCESS_KEY_ID: "",
|
|
821
|
+
S3_SECRET_ACCESS_KEY: "",
|
|
822
|
+
S3_BUCKET_NAME: "uploads",
|
|
823
|
+
S3_PUBLIC_URL: "",
|
|
824
|
+
S3_REGION: "auto",
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
// Public API
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
/**
|
|
831
|
+
* Install a single sail by name into a target project directory.
|
|
832
|
+
*
|
|
833
|
+
* Used by `keel sail add <name>` (the manage.ts entry point).
|
|
834
|
+
*
|
|
835
|
+
* If any marker comments are missing (user modified files), the installer
|
|
836
|
+
* skips those auto-insertions and prints clear manual instructions instead.
|
|
837
|
+
*
|
|
838
|
+
* @param sailName - The sail identifier (e.g., "google-oauth", "stripe")
|
|
839
|
+
* @param sailDir - Path to the sail definition (from the CLI package's bundled sails)
|
|
840
|
+
* @param projectDir - Path to the target project (cwd by default)
|
|
841
|
+
*/
|
|
842
|
+
export async function installSailByName(sailName, sailDir, projectDir) {
|
|
843
|
+
// Clear any previous manual steps
|
|
844
|
+
getManualSteps();
|
|
845
|
+
switch (sailName) {
|
|
846
|
+
case "google-oauth":
|
|
847
|
+
installGoogleOAuth(sailDir, projectDir);
|
|
848
|
+
break;
|
|
849
|
+
case "stripe":
|
|
850
|
+
installStripe(sailDir, projectDir);
|
|
851
|
+
break;
|
|
852
|
+
case "push-notifications":
|
|
853
|
+
installPushNotifications(sailDir, projectDir);
|
|
854
|
+
break;
|
|
855
|
+
case "analytics":
|
|
856
|
+
installAnalytics(sailDir, projectDir);
|
|
857
|
+
break;
|
|
858
|
+
case "admin-dashboard":
|
|
859
|
+
installAdminDashboard(sailDir, projectDir);
|
|
860
|
+
break;
|
|
861
|
+
case "i18n":
|
|
862
|
+
installI18n(sailDir, projectDir);
|
|
863
|
+
break;
|
|
864
|
+
case "rate-limiting":
|
|
865
|
+
installRateLimiting(sailDir, projectDir);
|
|
866
|
+
break;
|
|
867
|
+
case "file-uploads":
|
|
868
|
+
installFileUploads(sailDir, projectDir);
|
|
869
|
+
break;
|
|
870
|
+
case "gdpr":
|
|
871
|
+
installGdpr(sailDir, projectDir);
|
|
872
|
+
break;
|
|
873
|
+
case "r2-storage":
|
|
874
|
+
installR2Storage(sailDir, projectDir);
|
|
875
|
+
break;
|
|
876
|
+
default:
|
|
877
|
+
throw new Error(`Unknown sail: ${sailName}`);
|
|
878
|
+
}
|
|
879
|
+
// Print manual steps if any markers were missing
|
|
880
|
+
const steps = getManualSteps();
|
|
881
|
+
if (steps.length > 0) {
|
|
882
|
+
console.log();
|
|
883
|
+
console.log(chalk.yellow.bold(" ⚠ Some auto-insertions were skipped because marker comments were missing."));
|
|
884
|
+
console.log(chalk.yellow(" This usually means you've customized those files. Please add the following manually:\n"));
|
|
885
|
+
steps.forEach((step, i) => {
|
|
886
|
+
console.log(chalk.white(` ${i + 1}. ${step}`));
|
|
887
|
+
});
|
|
888
|
+
console.log();
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Install all selected sails into the scaffolded project.
|
|
893
|
+
*
|
|
894
|
+
* Used during project creation (the create.ts entry point).
|
|
895
|
+
*/
|
|
896
|
+
export async function installSails(config) {
|
|
897
|
+
// Clear any leftover manual steps from previous calls
|
|
898
|
+
manualSteps.length = 0;
|
|
899
|
+
const projectDir = resolve(process.cwd(), config.projectName);
|
|
900
|
+
const bundledSailsDir = getBundledSailsDir();
|
|
901
|
+
for (const sail of config.sails) {
|
|
902
|
+
const spinner = ora(` Installing ${sail}...`).start();
|
|
903
|
+
const sailDir = join(bundledSailsDir, sail);
|
|
904
|
+
try {
|
|
905
|
+
await installSailByName(sail, sailDir, projectDir);
|
|
906
|
+
const steps = getManualSteps();
|
|
907
|
+
if (steps.length > 0) {
|
|
908
|
+
spinner.warn(` ${sail} installed (some manual steps needed)`);
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
spinner.succeed(` ${sail} installed`);
|
|
912
|
+
}
|
|
913
|
+
// Update installed.json in the new project
|
|
914
|
+
const installedPath = join(projectDir, "sails", "installed.json");
|
|
915
|
+
if (existsSync(installedPath)) {
|
|
916
|
+
let installed;
|
|
917
|
+
try {
|
|
918
|
+
installed = JSON.parse(readFileSync(installedPath, "utf-8"));
|
|
919
|
+
}
|
|
920
|
+
catch (parseError) {
|
|
921
|
+
throw new Error(`Failed to parse ${installedPath}: ${parseError.message}`);
|
|
922
|
+
}
|
|
923
|
+
if (!installed.installed.includes(sail)) {
|
|
924
|
+
installed.installed.push(sail);
|
|
925
|
+
writeFileSync(installedPath, JSON.stringify(installed, null, 2) + "\n", "utf-8");
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
catch (error) {
|
|
930
|
+
spinner.fail(` Failed to install ${sail}`);
|
|
931
|
+
console.error(chalk.red(` ${error}`));
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
//# sourceMappingURL=sail-installer.js.map
|