@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.
Files changed (116) hide show
  1. package/dist/__tests__/cli.test.d.ts +2 -0
  2. package/dist/__tests__/cli.test.d.ts.map +1 -0
  3. package/dist/__tests__/cli.test.js +173 -0
  4. package/dist/__tests__/cli.test.js.map +1 -0
  5. package/dist/__tests__/registry.test.d.ts +2 -0
  6. package/dist/__tests__/registry.test.d.ts.map +1 -0
  7. package/dist/__tests__/registry.test.js +86 -0
  8. package/dist/__tests__/registry.test.js.map +1 -0
  9. package/dist/__tests__/sail-installer.test.d.ts +2 -0
  10. package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
  11. package/dist/__tests__/sail-installer.test.js +158 -0
  12. package/dist/__tests__/sail-installer.test.js.map +1 -0
  13. package/dist/create-runner.d.ts +11 -0
  14. package/dist/create-runner.d.ts.map +1 -0
  15. package/dist/create-runner.js +63 -0
  16. package/dist/create-runner.js.map +1 -0
  17. package/dist/create.d.ts +10 -0
  18. package/dist/create.d.ts.map +1 -0
  19. package/dist/create.js +15 -0
  20. package/dist/create.js.map +1 -0
  21. package/dist/manage.d.ts +24 -0
  22. package/dist/manage.d.ts.map +1 -0
  23. package/dist/manage.js +1461 -0
  24. package/dist/manage.js.map +1 -0
  25. package/dist/prompts.d.ts +36 -0
  26. package/dist/prompts.d.ts.map +1 -0
  27. package/dist/prompts.js +208 -0
  28. package/dist/prompts.js.map +1 -0
  29. package/dist/sail-installer.d.ts +37 -0
  30. package/dist/sail-installer.d.ts.map +1 -0
  31. package/dist/sail-installer.js +935 -0
  32. package/dist/sail-installer.js.map +1 -0
  33. package/dist/scaffold.d.ts +10 -0
  34. package/dist/scaffold.d.ts.map +1 -0
  35. package/dist/scaffold.js +297 -0
  36. package/dist/scaffold.js.map +1 -0
  37. package/package.json +57 -0
  38. package/sails/_template/addon.json +20 -0
  39. package/sails/_template/install.ts +402 -0
  40. package/sails/admin-dashboard/README.md +117 -0
  41. package/sails/admin-dashboard/addon.json +28 -0
  42. package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
  43. package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
  44. package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
  45. package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
  46. package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
  47. package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
  48. package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
  49. package/sails/admin-dashboard/install.ts +305 -0
  50. package/sails/analytics/README.md +178 -0
  51. package/sails/analytics/addon.json +27 -0
  52. package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
  53. package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
  54. package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
  55. package/sails/analytics/install.ts +297 -0
  56. package/sails/file-uploads/README.md +191 -0
  57. package/sails/file-uploads/addon.json +30 -0
  58. package/sails/file-uploads/files/backend/routes/files.ts +198 -0
  59. package/sails/file-uploads/files/backend/schema/files.ts +36 -0
  60. package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
  61. package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
  62. package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
  63. package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
  64. package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
  65. package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
  66. package/sails/file-uploads/install.ts +466 -0
  67. package/sails/gdpr/README.md +174 -0
  68. package/sails/gdpr/addon.json +27 -0
  69. package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
  70. package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
  71. package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
  72. package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
  73. package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
  74. package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
  75. package/sails/gdpr/install.ts +756 -0
  76. package/sails/google-oauth/README.md +121 -0
  77. package/sails/google-oauth/addon.json +22 -0
  78. package/sails/google-oauth/files/GoogleButton.tsx +50 -0
  79. package/sails/google-oauth/install.ts +252 -0
  80. package/sails/i18n/README.md +193 -0
  81. package/sails/i18n/addon.json +30 -0
  82. package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
  83. package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
  84. package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
  85. package/sails/i18n/files/frontend/locales/de/common.json +44 -0
  86. package/sails/i18n/files/frontend/locales/en/common.json +44 -0
  87. package/sails/i18n/install.ts +407 -0
  88. package/sails/push-notifications/README.md +163 -0
  89. package/sails/push-notifications/addon.json +31 -0
  90. package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
  91. package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
  92. package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
  93. package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
  94. package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
  95. package/sails/push-notifications/install.ts +384 -0
  96. package/sails/r2-storage/README.md +101 -0
  97. package/sails/r2-storage/addon.json +29 -0
  98. package/sails/r2-storage/files/backend/services/storage.ts +71 -0
  99. package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
  100. package/sails/r2-storage/install.ts +412 -0
  101. package/sails/rate-limiting/README.md +145 -0
  102. package/sails/rate-limiting/addon.json +20 -0
  103. package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
  104. package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
  105. package/sails/rate-limiting/install.ts +300 -0
  106. package/sails/registry.json +107 -0
  107. package/sails/stripe/README.md +214 -0
  108. package/sails/stripe/addon.json +24 -0
  109. package/sails/stripe/files/backend/routes/stripe.ts +154 -0
  110. package/sails/stripe/files/backend/schema/stripe.ts +74 -0
  111. package/sails/stripe/files/backend/services/stripe.ts +224 -0
  112. package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
  113. package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
  114. package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
  115. package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
  116. 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