@codaijs/keel 0.2.3 → 0.2.4

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 (80) hide show
  1. package/dist/__tests__/sail-installer.test.js +25 -25
  2. package/dist/sail-installer.js +174 -174
  3. package/dist/scaffold.js +68 -68
  4. package/package.json +58 -58
  5. package/sails/_template/addon.json +20 -20
  6. package/sails/_template/install.ts +402 -402
  7. package/sails/admin-dashboard/README.md +117 -117
  8. package/sails/admin-dashboard/addon.json +28 -28
  9. package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -34
  10. package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -243
  11. package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -40
  12. package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -240
  13. package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -149
  14. package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -173
  15. package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -203
  16. package/sails/admin-dashboard/install.ts +305 -305
  17. package/sails/analytics/README.md +178 -178
  18. package/sails/analytics/addon.json +27 -27
  19. package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -58
  20. package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -64
  21. package/sails/analytics/files/frontend/lib/analytics.ts +103 -103
  22. package/sails/analytics/install.ts +297 -297
  23. package/sails/file-uploads/addon.json +30 -30
  24. package/sails/file-uploads/files/backend/routes/files.ts +198 -198
  25. package/sails/file-uploads/files/backend/schema/files.ts +36 -36
  26. package/sails/file-uploads/files/backend/services/file-storage.ts +128 -128
  27. package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -248
  28. package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -147
  29. package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -106
  30. package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -118
  31. package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -37
  32. package/sails/file-uploads/install.ts +466 -466
  33. package/sails/gdpr/README.md +174 -174
  34. package/sails/gdpr/addon.json +27 -27
  35. package/sails/gdpr/files/backend/routes/gdpr.ts +140 -140
  36. package/sails/gdpr/files/backend/services/gdpr.ts +293 -293
  37. package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -97
  38. package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -192
  39. package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -75
  40. package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -186
  41. package/sails/gdpr/install.ts +756 -756
  42. package/sails/google-oauth/README.md +121 -121
  43. package/sails/google-oauth/addon.json +22 -22
  44. package/sails/google-oauth/files/GoogleButton.tsx +50 -50
  45. package/sails/google-oauth/install.ts +252 -252
  46. package/sails/i18n/README.md +193 -193
  47. package/sails/i18n/addon.json +30 -30
  48. package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -108
  49. package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -31
  50. package/sails/i18n/files/frontend/lib/i18n.ts +32 -32
  51. package/sails/i18n/files/frontend/locales/de/common.json +44 -44
  52. package/sails/i18n/files/frontend/locales/en/common.json +44 -44
  53. package/sails/i18n/install.ts +407 -407
  54. package/sails/push-notifications/README.md +163 -163
  55. package/sails/push-notifications/addon.json +31 -31
  56. package/sails/push-notifications/files/backend/routes/notifications.ts +153 -153
  57. package/sails/push-notifications/files/backend/schema/notifications.ts +31 -31
  58. package/sails/push-notifications/files/backend/services/notifications.ts +117 -117
  59. package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -12
  60. package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -154
  61. package/sails/push-notifications/install.ts +384 -384
  62. package/sails/r2-storage/addon.json +29 -29
  63. package/sails/r2-storage/files/backend/services/storage.ts +71 -71
  64. package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -167
  65. package/sails/r2-storage/install.ts +412 -412
  66. package/sails/rate-limiting/addon.json +20 -20
  67. package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -104
  68. package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -137
  69. package/sails/rate-limiting/install.ts +300 -300
  70. package/sails/registry.json +107 -107
  71. package/sails/stripe/README.md +214 -214
  72. package/sails/stripe/addon.json +24 -24
  73. package/sails/stripe/files/backend/routes/stripe.ts +154 -154
  74. package/sails/stripe/files/backend/schema/stripe.ts +74 -74
  75. package/sails/stripe/files/backend/services/stripe.ts +224 -224
  76. package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -135
  77. package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -86
  78. package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -116
  79. package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -226
  80. package/sails/stripe/install.ts +378 -378
@@ -1,412 +1,412 @@
1
- /**
2
- * Cloudflare R2 Storage Sail Installer
3
- *
4
- * Adds file uploads via Cloudflare R2 with presigned URLs.
5
- * Includes profile picture upload component and avatar management endpoints.
6
- *
7
- * Usage:
8
- * npx tsx sails/r2-storage/install.ts
9
- */
10
-
11
- import {
12
- readFileSync,
13
- writeFileSync,
14
- copyFileSync,
15
- existsSync,
16
- mkdirSync,
17
- } from "node:fs";
18
- import { resolve, dirname, join } from "node:path";
19
- import { execSync } from "node:child_process";
20
- import { input, confirm } from "@inquirer/prompts";
21
-
22
- // ---------------------------------------------------------------------------
23
- // Paths
24
- // ---------------------------------------------------------------------------
25
-
26
- const SAIL_DIR = dirname(new URL(import.meta.url).pathname);
27
- const PROJECT_ROOT = resolve(SAIL_DIR, "../..");
28
- const BACKEND_ROOT = join(PROJECT_ROOT, "packages/backend");
29
- const FRONTEND_ROOT = join(PROJECT_ROOT, "packages/frontend");
30
-
31
- // ---------------------------------------------------------------------------
32
- // Helpers
33
- // ---------------------------------------------------------------------------
34
-
35
- interface SailManifest {
36
- name: string;
37
- displayName: string;
38
- version: string;
39
- requiredEnvVars: { key: string; description: string }[];
40
- dependencies: { backend: Record<string, string>; frontend: Record<string, string> };
41
- }
42
-
43
- function loadManifest(): SailManifest {
44
- return JSON.parse(readFileSync(join(SAIL_DIR, "addon.json"), "utf-8"));
45
- }
46
-
47
- function insertAtMarker(filePath: string, marker: string, code: string): void {
48
- if (!existsSync(filePath)) {
49
- console.warn(` Warning: File not found: ${filePath}`);
50
- return;
51
- }
52
- let content = readFileSync(filePath, "utf-8");
53
- if (!content.includes(marker)) {
54
- console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
55
- return;
56
- }
57
- if (content.includes(code.trim())) {
58
- console.log(` Skipped (already present) -> ${filePath}`);
59
- return;
60
- }
61
- content = content.replace(marker, `${marker}\n${code}`);
62
- writeFileSync(filePath, content, "utf-8");
63
- console.log(` Modified -> ${filePath}`);
64
- }
65
-
66
- function copyFile(src: string, dest: string, label: string): void {
67
- mkdirSync(dirname(dest), { recursive: true });
68
- copyFileSync(src, dest);
69
- console.log(` Copied -> ${label}`);
70
- }
71
-
72
- function appendToEnvFiles(entries: Record<string, string>, section: string): void {
73
- for (const envFile of [".env.example", ".env"]) {
74
- const envPath = join(PROJECT_ROOT, envFile);
75
- if (!existsSync(envPath)) continue;
76
- let content = readFileSync(envPath, "utf-8");
77
- const lines: string[] = [];
78
- for (const [key, val] of Object.entries(entries)) {
79
- if (!content.includes(key)) lines.push(`${key}=${val}`);
80
- }
81
- if (lines.length > 0) {
82
- content += `\n# ${section}\n${lines.join("\n")}\n`;
83
- writeFileSync(envPath, content, "utf-8");
84
- console.log(` Updated ${envFile}`);
85
- }
86
- }
87
- }
88
-
89
- function installDeps(deps: Record<string, string>, workspace: string): void {
90
- const entries = Object.entries(deps);
91
- if (entries.length === 0) return;
92
- const packages = entries.map(([n, v]) => `${n}@${v}`).join(" ");
93
- const cmd = `npm install ${packages} --workspace=${workspace}`;
94
- console.log(` Running: ${cmd}`);
95
- execSync(cmd, { cwd: PROJECT_ROOT, stdio: "inherit" });
96
- }
97
-
98
- // ---------------------------------------------------------------------------
99
- // Main
100
- // ---------------------------------------------------------------------------
101
-
102
- async function main(): Promise<void> {
103
- const manifest = loadManifest();
104
-
105
- // -- Step 1: Welcome message ------------------------------------------------
106
- console.log("\n------------------------------------------------------------");
107
- console.log(` Cloudflare R2 Storage Sail Installer (v${manifest.version})`);
108
- console.log("------------------------------------------------------------");
109
- console.log();
110
- console.log(" This sail adds file upload support via Cloudflare R2:");
111
- console.log(" - Presigned URL generation for direct browser uploads");
112
- console.log(" - Profile picture upload component");
113
- console.log(" - Avatar management API endpoints (upload URL + delete)");
114
- console.log(" - S3-compatible storage via Cloudflare R2");
115
- console.log();
116
-
117
- const pkgPath = join(PROJECT_ROOT, "package.json");
118
- if (existsSync(pkgPath)) {
119
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
120
- console.log(` Template version: ${pkg.version ?? "unknown"}`);
121
- console.log();
122
- }
123
-
124
- // -- Step 2: R2 bucket setup ------------------------------------------------
125
- const hasBucket = await confirm({
126
- message: "Do you already have a Cloudflare R2 bucket set up?",
127
- default: false,
128
- });
129
-
130
- if (!hasBucket) {
131
- console.log();
132
- console.log(" Create a Cloudflare R2 bucket:");
133
- console.log();
134
- console.log(" 1. Go to the Cloudflare dashboard:");
135
- console.log(" https://dash.cloudflare.com/?to=/:account/r2/new");
136
- console.log(" 2. Click 'Create bucket'");
137
- console.log(" 3. Choose a bucket name (e.g., 'avatars' or 'uploads')");
138
- console.log(" 4. Select a location hint closest to your users");
139
- console.log(" 5. Click 'Create bucket'");
140
- console.log();
141
- console.log(" Then create an API token:");
142
- console.log(" 1. Go to R2 > Overview > Manage R2 API Tokens");
143
- console.log(" 2. Click 'Create API token'");
144
- console.log(" 3. Give it 'Object Read & Write' permissions");
145
- console.log(" 4. Copy the Access Key ID and Secret Access Key");
146
- console.log();
147
-
148
- await confirm({
149
- message: "I have created my R2 bucket and API token, ready to continue",
150
- default: false,
151
- });
152
- }
153
-
154
- // -- Step 3: Collect env vars -----------------------------------------------
155
- console.log();
156
- console.log(" Enter your Cloudflare R2 credentials:");
157
- console.log(" (Find your Account ID in the Cloudflare dashboard URL or R2 overview page)");
158
- console.log();
159
-
160
- const r2AccountId = await input({
161
- message: "R2 Account ID:",
162
- validate: (value) => {
163
- if (!value || value.trim().length === 0) return "Account ID is required.";
164
- return true;
165
- },
166
- });
167
-
168
- const r2AccessKeyId = await input({
169
- message: "R2 Access Key ID:",
170
- validate: (value) => {
171
- if (!value || value.trim().length === 0) return "Access Key ID is required.";
172
- return true;
173
- },
174
- });
175
-
176
- const r2SecretAccessKey = await input({
177
- message: "R2 Secret Access Key:",
178
- validate: (value) => {
179
- if (!value || value.trim().length === 0) return "Secret Access Key is required.";
180
- return true;
181
- },
182
- });
183
-
184
- const r2BucketName = await input({
185
- message: "R2 Bucket Name:",
186
- default: "avatars",
187
- validate: (value) => {
188
- if (!value || value.trim().length === 0) return "Bucket name is required.";
189
- return true;
190
- },
191
- });
192
-
193
- const r2PublicUrl = await input({
194
- message: "R2 Public URL (e.g., https://pub-xxx.r2.dev):",
195
- validate: (value) => {
196
- if (!value || value.trim().length === 0) return "Public URL is required.";
197
- if (!value.startsWith("http")) return "Public URL should start with http:// or https://";
198
- return true;
199
- },
200
- });
201
-
202
- // -- Step 4: CORS reminder --------------------------------------------------
203
- console.log();
204
- console.log(" Important: Configure CORS on your R2 bucket!");
205
- console.log();
206
- console.log(" Go to your bucket settings and add a CORS policy:");
207
- console.log(" [");
208
- console.log(' {');
209
- console.log(' "AllowedOrigins": ["http://localhost:5173", "https://yourdomain.com"],');
210
- console.log(' "AllowedMethods": ["GET", "PUT"],');
211
- console.log(' "AllowedHeaders": ["Content-Type"],');
212
- console.log(' "MaxAgeSeconds": 3600');
213
- console.log(" }");
214
- console.log(" ]");
215
- console.log();
216
-
217
- await confirm({
218
- message: "I have configured CORS (or will do it later)",
219
- default: true,
220
- });
221
-
222
- // -- Step 5: Summary --------------------------------------------------------
223
- console.log();
224
- console.log(" Summary of changes:");
225
- console.log(" -------------------");
226
- console.log(" Files to copy (backend):");
227
- console.log(" + packages/backend/src/services/storage.ts");
228
- console.log();
229
- console.log(" Files to copy (frontend):");
230
- console.log(" + packages/frontend/src/components/profile/ProfilePictureUpload.tsx");
231
- console.log();
232
- console.log(" Files to modify:");
233
- console.log(" ~ packages/backend/src/routes/profile.ts (add avatar endpoints)");
234
- console.log(" ~ packages/backend/src/env.ts (add R2 env vars)");
235
- console.log(" ~ packages/frontend/src/components/profile/ProfilePage.tsx (add upload component)");
236
- console.log(" ~ .env.example / .env");
237
- console.log();
238
- console.log(" Environment variables:");
239
- console.log(` R2_ACCOUNT_ID=${r2AccountId.slice(0, 8)}...`);
240
- console.log(` R2_ACCESS_KEY_ID=${r2AccessKeyId.slice(0, 8)}...`);
241
- console.log(` R2_SECRET_ACCESS_KEY=${r2SecretAccessKey.slice(0, 8)}...`);
242
- console.log(` R2_BUCKET_NAME=${r2BucketName}`);
243
- console.log(` R2_PUBLIC_URL=${r2PublicUrl}`);
244
- console.log();
245
-
246
- const proceed = await confirm({ message: "Proceed with installation?", default: true });
247
- if (!proceed) { console.log("\n Installation cancelled.\n"); process.exit(0); }
248
-
249
- // -- Step 6: Execute --------------------------------------------------------
250
- console.log();
251
- console.log(" Installing...");
252
- console.log();
253
-
254
- console.log(" Copying backend files...");
255
- copyFile(
256
- join(SAIL_DIR, "files/backend/services/storage.ts"),
257
- join(BACKEND_ROOT, "src/services/storage.ts"),
258
- "src/services/storage.ts",
259
- );
260
-
261
- console.log();
262
- console.log(" Copying frontend files...");
263
- copyFile(
264
- join(SAIL_DIR, "files/frontend/components/ProfilePictureUpload.tsx"),
265
- join(FRONTEND_ROOT, "src/components/profile/ProfilePictureUpload.tsx"),
266
- "src/components/profile/ProfilePictureUpload.tsx",
267
- );
268
-
269
- console.log();
270
- console.log(" Modifying backend files...");
271
-
272
- // Add R2 env vars to env.ts
273
- insertAtMarker(
274
- join(BACKEND_ROOT, "src/env.ts"),
275
- "// [SAIL_ENV_VARS]",
276
- ` R2_ACCOUNT_ID: z.string().default(""),\n R2_ACCESS_KEY_ID: z.string().default(""),\n R2_SECRET_ACCESS_KEY: z.string().default(""),\n R2_BUCKET_NAME: z.string().default("avatars"),\n R2_PUBLIC_URL: z.string().default(""),`,
277
- );
278
-
279
- // Add avatar routes to profile.ts — insert import and route blocks
280
- const profilePath = join(BACKEND_ROOT, "src/routes/profile.ts");
281
- if (existsSync(profilePath)) {
282
- let profileContent = readFileSync(profilePath, "utf-8");
283
-
284
- // Add storage import if not present
285
- if (!profileContent.includes("storage")) {
286
- profileContent = profileContent.replace(
287
- 'import { db } from "../db/index.js";',
288
- 'import { db } from "../db/index.js";\nimport { generateUploadUrl, deleteObject } from "../services/storage.js";',
289
- );
290
- }
291
-
292
- // Add avatar routes if not present
293
- if (!profileContent.includes("/avatar/upload-url")) {
294
- const avatarRoutes = `
295
- // POST /avatar/upload-url — generate presigned upload URL
296
- router.post("/avatar/upload-url", async (req, res) => {
297
- const { fileType } = req.body as { fileType?: string };
298
-
299
- if (!fileType || typeof fileType !== "string") {
300
- res.status(400).json({ error: "fileType is required" });
301
- return;
302
- }
303
-
304
- const result = await generateUploadUrl(req.user!.id, fileType);
305
- res.json(result);
306
- });
307
-
308
- // DELETE /avatar — delete current avatar
309
- router.delete("/avatar", async (req, res) => {
310
- const user = req.user!;
311
-
312
- if (user.image) {
313
- try {
314
- // Extract key from the image URL or stored key
315
- await deleteObject(user.image);
316
- } catch {
317
- // Continue even if R2 deletion fails
318
- }
319
- }
320
-
321
- const [updated] = await db
322
- .update(users)
323
- .set({ image: null, updatedAt: new Date() })
324
- .where(eq(users.id, user.id))
325
- .returning();
326
-
327
- res.json({ user: updated });
328
- });`;
329
-
330
- // Insert before "export default router"
331
- profileContent = profileContent.replace(
332
- "export default router;",
333
- `${avatarRoutes}\n\nexport default router;`,
334
- );
335
- }
336
-
337
- writeFileSync(profilePath, profileContent, "utf-8");
338
- console.log(` Modified -> ${profilePath}`);
339
- }
340
-
341
- console.log();
342
- console.log(" Modifying frontend files...");
343
-
344
- // Add ProfilePictureUpload to ProfilePage.tsx
345
- const profilePagePath = join(FRONTEND_ROOT, "src/components/profile/ProfilePage.tsx");
346
- if (existsSync(profilePagePath)) {
347
- let pageContent = readFileSync(profilePagePath, "utf-8");
348
-
349
- // Add import if not present
350
- if (!pageContent.includes("ProfilePictureUpload")) {
351
- pageContent = pageContent.replace(
352
- 'import { apiPatch } from "@/lib/api";',
353
- 'import { apiPatch } from "@/lib/api";\nimport ProfilePictureUpload from "./ProfilePictureUpload";',
354
- );
355
-
356
- // Add the component in the profile card layout
357
- pageContent = pageContent.replace(
358
- '<div className="flex flex-col items-start gap-6 sm:flex-row">',
359
- '<div className="flex flex-col items-start gap-6 sm:flex-row">\n <ProfilePictureUpload />',
360
- );
361
- }
362
-
363
- writeFileSync(profilePagePath, pageContent, "utf-8");
364
- console.log(` Modified -> ${profilePagePath}`);
365
- }
366
-
367
- console.log();
368
- console.log(" Installing dependencies...");
369
- installDeps(manifest.dependencies.backend, "packages/backend");
370
- installDeps(manifest.dependencies.frontend, "packages/frontend");
371
-
372
- console.log();
373
- console.log(" Updating environment files...");
374
- appendToEnvFiles(
375
- {
376
- R2_ACCOUNT_ID: r2AccountId,
377
- R2_ACCESS_KEY_ID: r2AccessKeyId,
378
- R2_SECRET_ACCESS_KEY: r2SecretAccessKey,
379
- R2_BUCKET_NAME: r2BucketName,
380
- R2_PUBLIC_URL: r2PublicUrl,
381
- },
382
- "Cloudflare R2 Storage",
383
- );
384
-
385
- // -- Step 7: Next steps -----------------------------------------------------
386
- console.log();
387
- console.log("------------------------------------------------------------");
388
- console.log(" Cloudflare R2 Storage installed successfully!");
389
- console.log("------------------------------------------------------------");
390
- console.log();
391
- console.log(" Next steps:");
392
- console.log();
393
- console.log(" 1. Make sure CORS is configured on your R2 bucket");
394
- console.log(" (allow PUT from your frontend origin)");
395
- console.log();
396
- console.log(" 2. If using a custom domain for R2 public access,");
397
- console.log(" set R2_PUBLIC_URL to that domain in your .env");
398
- console.log();
399
- console.log(" 3. Start your dev server:");
400
- console.log(" npm run dev");
401
- console.log();
402
- console.log(" 4. Navigate to your profile page and try uploading");
403
- console.log(" a profile picture to verify the integration works.");
404
- console.log();
405
- console.log(" Troubleshooting:");
406
- console.log(" - CORS errors: Check your R2 bucket CORS configuration");
407
- console.log(" - 403 errors: Verify your API token has read/write permissions");
408
- console.log(" - Upload fails: Check that R2_ACCOUNT_ID is correct");
409
- console.log();
410
- }
411
-
412
- main().catch((err) => { console.error("Installation failed:", err); process.exit(1); });
1
+ /**
2
+ * Cloudflare R2 Storage Sail Installer
3
+ *
4
+ * Adds file uploads via Cloudflare R2 with presigned URLs.
5
+ * Includes profile picture upload component and avatar management endpoints.
6
+ *
7
+ * Usage:
8
+ * npx tsx sails/r2-storage/install.ts
9
+ */
10
+
11
+ import {
12
+ readFileSync,
13
+ writeFileSync,
14
+ copyFileSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ } from "node:fs";
18
+ import { resolve, dirname, join } from "node:path";
19
+ import { execSync } from "node:child_process";
20
+ import { input, confirm } from "@inquirer/prompts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Paths
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const SAIL_DIR = dirname(new URL(import.meta.url).pathname);
27
+ const PROJECT_ROOT = resolve(SAIL_DIR, "../..");
28
+ const BACKEND_ROOT = join(PROJECT_ROOT, "packages/backend");
29
+ const FRONTEND_ROOT = join(PROJECT_ROOT, "packages/frontend");
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface SailManifest {
36
+ name: string;
37
+ displayName: string;
38
+ version: string;
39
+ requiredEnvVars: { key: string; description: string }[];
40
+ dependencies: { backend: Record<string, string>; frontend: Record<string, string> };
41
+ }
42
+
43
+ function loadManifest(): SailManifest {
44
+ return JSON.parse(readFileSync(join(SAIL_DIR, "addon.json"), "utf-8"));
45
+ }
46
+
47
+ function insertAtMarker(filePath: string, marker: string, code: string): void {
48
+ if (!existsSync(filePath)) {
49
+ console.warn(` Warning: File not found: ${filePath}`);
50
+ return;
51
+ }
52
+ let content = readFileSync(filePath, "utf-8");
53
+ if (!content.includes(marker)) {
54
+ console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
55
+ return;
56
+ }
57
+ if (content.includes(code.trim())) {
58
+ console.log(` Skipped (already present) -> ${filePath}`);
59
+ return;
60
+ }
61
+ content = content.replace(marker, `${marker}\n${code}`);
62
+ writeFileSync(filePath, content, "utf-8");
63
+ console.log(` Modified -> ${filePath}`);
64
+ }
65
+
66
+ function copyFile(src: string, dest: string, label: string): void {
67
+ mkdirSync(dirname(dest), { recursive: true });
68
+ copyFileSync(src, dest);
69
+ console.log(` Copied -> ${label}`);
70
+ }
71
+
72
+ function appendToEnvFiles(entries: Record<string, string>, section: string): void {
73
+ for (const envFile of [".env.example", ".env"]) {
74
+ const envPath = join(PROJECT_ROOT, envFile);
75
+ if (!existsSync(envPath)) continue;
76
+ let content = readFileSync(envPath, "utf-8");
77
+ const lines: string[] = [];
78
+ for (const [key, val] of Object.entries(entries)) {
79
+ if (!content.includes(key)) lines.push(`${key}=${val}`);
80
+ }
81
+ if (lines.length > 0) {
82
+ content += `\n# ${section}\n${lines.join("\n")}\n`;
83
+ writeFileSync(envPath, content, "utf-8");
84
+ console.log(` Updated ${envFile}`);
85
+ }
86
+ }
87
+ }
88
+
89
+ function installDeps(deps: Record<string, string>, workspace: string): void {
90
+ const entries = Object.entries(deps);
91
+ if (entries.length === 0) return;
92
+ const packages = entries.map(([n, v]) => `${n}@${v}`).join(" ");
93
+ const cmd = `npm install ${packages} --workspace=${workspace}`;
94
+ console.log(` Running: ${cmd}`);
95
+ execSync(cmd, { cwd: PROJECT_ROOT, stdio: "inherit" });
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Main
100
+ // ---------------------------------------------------------------------------
101
+
102
+ async function main(): Promise<void> {
103
+ const manifest = loadManifest();
104
+
105
+ // -- Step 1: Welcome message ------------------------------------------------
106
+ console.log("\n------------------------------------------------------------");
107
+ console.log(` Cloudflare R2 Storage Sail Installer (v${manifest.version})`);
108
+ console.log("------------------------------------------------------------");
109
+ console.log();
110
+ console.log(" This sail adds file upload support via Cloudflare R2:");
111
+ console.log(" - Presigned URL generation for direct browser uploads");
112
+ console.log(" - Profile picture upload component");
113
+ console.log(" - Avatar management API endpoints (upload URL + delete)");
114
+ console.log(" - S3-compatible storage via Cloudflare R2");
115
+ console.log();
116
+
117
+ const pkgPath = join(PROJECT_ROOT, "package.json");
118
+ if (existsSync(pkgPath)) {
119
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
120
+ console.log(` Template version: ${pkg.version ?? "unknown"}`);
121
+ console.log();
122
+ }
123
+
124
+ // -- Step 2: R2 bucket setup ------------------------------------------------
125
+ const hasBucket = await confirm({
126
+ message: "Do you already have a Cloudflare R2 bucket set up?",
127
+ default: false,
128
+ });
129
+
130
+ if (!hasBucket) {
131
+ console.log();
132
+ console.log(" Create a Cloudflare R2 bucket:");
133
+ console.log();
134
+ console.log(" 1. Go to the Cloudflare dashboard:");
135
+ console.log(" https://dash.cloudflare.com/?to=/:account/r2/new");
136
+ console.log(" 2. Click 'Create bucket'");
137
+ console.log(" 3. Choose a bucket name (e.g., 'avatars' or 'uploads')");
138
+ console.log(" 4. Select a location hint closest to your users");
139
+ console.log(" 5. Click 'Create bucket'");
140
+ console.log();
141
+ console.log(" Then create an API token:");
142
+ console.log(" 1. Go to R2 > Overview > Manage R2 API Tokens");
143
+ console.log(" 2. Click 'Create API token'");
144
+ console.log(" 3. Give it 'Object Read & Write' permissions");
145
+ console.log(" 4. Copy the Access Key ID and Secret Access Key");
146
+ console.log();
147
+
148
+ await confirm({
149
+ message: "I have created my R2 bucket and API token, ready to continue",
150
+ default: false,
151
+ });
152
+ }
153
+
154
+ // -- Step 3: Collect env vars -----------------------------------------------
155
+ console.log();
156
+ console.log(" Enter your Cloudflare R2 credentials:");
157
+ console.log(" (Find your Account ID in the Cloudflare dashboard URL or R2 overview page)");
158
+ console.log();
159
+
160
+ const r2AccountId = await input({
161
+ message: "R2 Account ID:",
162
+ validate: (value) => {
163
+ if (!value || value.trim().length === 0) return "Account ID is required.";
164
+ return true;
165
+ },
166
+ });
167
+
168
+ const r2AccessKeyId = await input({
169
+ message: "R2 Access Key ID:",
170
+ validate: (value) => {
171
+ if (!value || value.trim().length === 0) return "Access Key ID is required.";
172
+ return true;
173
+ },
174
+ });
175
+
176
+ const r2SecretAccessKey = await input({
177
+ message: "R2 Secret Access Key:",
178
+ validate: (value) => {
179
+ if (!value || value.trim().length === 0) return "Secret Access Key is required.";
180
+ return true;
181
+ },
182
+ });
183
+
184
+ const r2BucketName = await input({
185
+ message: "R2 Bucket Name:",
186
+ default: "avatars",
187
+ validate: (value) => {
188
+ if (!value || value.trim().length === 0) return "Bucket name is required.";
189
+ return true;
190
+ },
191
+ });
192
+
193
+ const r2PublicUrl = await input({
194
+ message: "R2 Public URL (e.g., https://pub-xxx.r2.dev):",
195
+ validate: (value) => {
196
+ if (!value || value.trim().length === 0) return "Public URL is required.";
197
+ if (!value.startsWith("http")) return "Public URL should start with http:// or https://";
198
+ return true;
199
+ },
200
+ });
201
+
202
+ // -- Step 4: CORS reminder --------------------------------------------------
203
+ console.log();
204
+ console.log(" Important: Configure CORS on your R2 bucket!");
205
+ console.log();
206
+ console.log(" Go to your bucket settings and add a CORS policy:");
207
+ console.log(" [");
208
+ console.log(' {');
209
+ console.log(' "AllowedOrigins": ["http://localhost:5173", "https://yourdomain.com"],');
210
+ console.log(' "AllowedMethods": ["GET", "PUT"],');
211
+ console.log(' "AllowedHeaders": ["Content-Type"],');
212
+ console.log(' "MaxAgeSeconds": 3600');
213
+ console.log(" }");
214
+ console.log(" ]");
215
+ console.log();
216
+
217
+ await confirm({
218
+ message: "I have configured CORS (or will do it later)",
219
+ default: true,
220
+ });
221
+
222
+ // -- Step 5: Summary --------------------------------------------------------
223
+ console.log();
224
+ console.log(" Summary of changes:");
225
+ console.log(" -------------------");
226
+ console.log(" Files to copy (backend):");
227
+ console.log(" + packages/backend/src/services/storage.ts");
228
+ console.log();
229
+ console.log(" Files to copy (frontend):");
230
+ console.log(" + packages/frontend/src/components/profile/ProfilePictureUpload.tsx");
231
+ console.log();
232
+ console.log(" Files to modify:");
233
+ console.log(" ~ packages/backend/src/routes/profile.ts (add avatar endpoints)");
234
+ console.log(" ~ packages/backend/src/env.ts (add R2 env vars)");
235
+ console.log(" ~ packages/frontend/src/components/profile/ProfilePage.tsx (add upload component)");
236
+ console.log(" ~ .env.example / .env");
237
+ console.log();
238
+ console.log(" Environment variables:");
239
+ console.log(` R2_ACCOUNT_ID=${r2AccountId.slice(0, 8)}...`);
240
+ console.log(` R2_ACCESS_KEY_ID=${r2AccessKeyId.slice(0, 8)}...`);
241
+ console.log(` R2_SECRET_ACCESS_KEY=${r2SecretAccessKey.slice(0, 8)}...`);
242
+ console.log(` R2_BUCKET_NAME=${r2BucketName}`);
243
+ console.log(` R2_PUBLIC_URL=${r2PublicUrl}`);
244
+ console.log();
245
+
246
+ const proceed = await confirm({ message: "Proceed with installation?", default: true });
247
+ if (!proceed) { console.log("\n Installation cancelled.\n"); process.exit(0); }
248
+
249
+ // -- Step 6: Execute --------------------------------------------------------
250
+ console.log();
251
+ console.log(" Installing...");
252
+ console.log();
253
+
254
+ console.log(" Copying backend files...");
255
+ copyFile(
256
+ join(SAIL_DIR, "files/backend/services/storage.ts"),
257
+ join(BACKEND_ROOT, "src/services/storage.ts"),
258
+ "src/services/storage.ts",
259
+ );
260
+
261
+ console.log();
262
+ console.log(" Copying frontend files...");
263
+ copyFile(
264
+ join(SAIL_DIR, "files/frontend/components/ProfilePictureUpload.tsx"),
265
+ join(FRONTEND_ROOT, "src/components/profile/ProfilePictureUpload.tsx"),
266
+ "src/components/profile/ProfilePictureUpload.tsx",
267
+ );
268
+
269
+ console.log();
270
+ console.log(" Modifying backend files...");
271
+
272
+ // Add R2 env vars to env.ts
273
+ insertAtMarker(
274
+ join(BACKEND_ROOT, "src/env.ts"),
275
+ "// [SAIL_ENV_VARS]",
276
+ ` R2_ACCOUNT_ID: z.string().default(""),\n R2_ACCESS_KEY_ID: z.string().default(""),\n R2_SECRET_ACCESS_KEY: z.string().default(""),\n R2_BUCKET_NAME: z.string().default("avatars"),\n R2_PUBLIC_URL: z.string().default(""),`,
277
+ );
278
+
279
+ // Add avatar routes to profile.ts — insert import and route blocks
280
+ const profilePath = join(BACKEND_ROOT, "src/routes/profile.ts");
281
+ if (existsSync(profilePath)) {
282
+ let profileContent = readFileSync(profilePath, "utf-8");
283
+
284
+ // Add storage import if not present
285
+ if (!profileContent.includes("storage")) {
286
+ profileContent = profileContent.replace(
287
+ 'import { db } from "../db/index.js";',
288
+ 'import { db } from "../db/index.js";\nimport { generateUploadUrl, deleteObject } from "../services/storage.js";',
289
+ );
290
+ }
291
+
292
+ // Add avatar routes if not present
293
+ if (!profileContent.includes("/avatar/upload-url")) {
294
+ const avatarRoutes = `
295
+ // POST /avatar/upload-url — generate presigned upload URL
296
+ router.post("/avatar/upload-url", async (req, res) => {
297
+ const { fileType } = req.body as { fileType?: string };
298
+
299
+ if (!fileType || typeof fileType !== "string") {
300
+ res.status(400).json({ error: "fileType is required" });
301
+ return;
302
+ }
303
+
304
+ const result = await generateUploadUrl(req.user!.id, fileType);
305
+ res.json(result);
306
+ });
307
+
308
+ // DELETE /avatar — delete current avatar
309
+ router.delete("/avatar", async (req, res) => {
310
+ const user = req.user!;
311
+
312
+ if (user.image) {
313
+ try {
314
+ // Extract key from the image URL or stored key
315
+ await deleteObject(user.image);
316
+ } catch {
317
+ // Continue even if R2 deletion fails
318
+ }
319
+ }
320
+
321
+ const [updated] = await db
322
+ .update(users)
323
+ .set({ image: null, updatedAt: new Date() })
324
+ .where(eq(users.id, user.id))
325
+ .returning();
326
+
327
+ res.json({ user: updated });
328
+ });`;
329
+
330
+ // Insert before "export default router"
331
+ profileContent = profileContent.replace(
332
+ "export default router;",
333
+ `${avatarRoutes}\n\nexport default router;`,
334
+ );
335
+ }
336
+
337
+ writeFileSync(profilePath, profileContent, "utf-8");
338
+ console.log(` Modified -> ${profilePath}`);
339
+ }
340
+
341
+ console.log();
342
+ console.log(" Modifying frontend files...");
343
+
344
+ // Add ProfilePictureUpload to ProfilePage.tsx
345
+ const profilePagePath = join(FRONTEND_ROOT, "src/components/profile/ProfilePage.tsx");
346
+ if (existsSync(profilePagePath)) {
347
+ let pageContent = readFileSync(profilePagePath, "utf-8");
348
+
349
+ // Add import if not present
350
+ if (!pageContent.includes("ProfilePictureUpload")) {
351
+ pageContent = pageContent.replace(
352
+ 'import { apiPatch } from "@/lib/api";',
353
+ 'import { apiPatch } from "@/lib/api";\nimport ProfilePictureUpload from "./ProfilePictureUpload";',
354
+ );
355
+
356
+ // Add the component in the profile card layout
357
+ pageContent = pageContent.replace(
358
+ '<div className="flex flex-col items-start gap-6 sm:flex-row">',
359
+ '<div className="flex flex-col items-start gap-6 sm:flex-row">\n <ProfilePictureUpload />',
360
+ );
361
+ }
362
+
363
+ writeFileSync(profilePagePath, pageContent, "utf-8");
364
+ console.log(` Modified -> ${profilePagePath}`);
365
+ }
366
+
367
+ console.log();
368
+ console.log(" Installing dependencies...");
369
+ installDeps(manifest.dependencies.backend, "packages/backend");
370
+ installDeps(manifest.dependencies.frontend, "packages/frontend");
371
+
372
+ console.log();
373
+ console.log(" Updating environment files...");
374
+ appendToEnvFiles(
375
+ {
376
+ R2_ACCOUNT_ID: r2AccountId,
377
+ R2_ACCESS_KEY_ID: r2AccessKeyId,
378
+ R2_SECRET_ACCESS_KEY: r2SecretAccessKey,
379
+ R2_BUCKET_NAME: r2BucketName,
380
+ R2_PUBLIC_URL: r2PublicUrl,
381
+ },
382
+ "Cloudflare R2 Storage",
383
+ );
384
+
385
+ // -- Step 7: Next steps -----------------------------------------------------
386
+ console.log();
387
+ console.log("------------------------------------------------------------");
388
+ console.log(" Cloudflare R2 Storage installed successfully!");
389
+ console.log("------------------------------------------------------------");
390
+ console.log();
391
+ console.log(" Next steps:");
392
+ console.log();
393
+ console.log(" 1. Make sure CORS is configured on your R2 bucket");
394
+ console.log(" (allow PUT from your frontend origin)");
395
+ console.log();
396
+ console.log(" 2. If using a custom domain for R2 public access,");
397
+ console.log(" set R2_PUBLIC_URL to that domain in your .env");
398
+ console.log();
399
+ console.log(" 3. Start your dev server:");
400
+ console.log(" npm run dev");
401
+ console.log();
402
+ console.log(" 4. Navigate to your profile page and try uploading");
403
+ console.log(" a profile picture to verify the integration works.");
404
+ console.log();
405
+ console.log(" Troubleshooting:");
406
+ console.log(" - CORS errors: Check your R2 bucket CORS configuration");
407
+ console.log(" - 403 errors: Verify your API token has read/write permissions");
408
+ console.log(" - Upload fails: Check that R2_ACCOUNT_ID is correct");
409
+ console.log();
410
+ }
411
+
412
+ main().catch((err) => { console.error("Installation failed:", err); process.exit(1); });