@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,167 @@
1
+ import { useState, useRef } from "react";
2
+ import { useAuth } from "@/hooks/useAuth";
3
+ import { apiPost, apiPatch } from "@/lib/api";
4
+ import { isNative } from "@/lib/capacitor";
5
+
6
+ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
7
+
8
+ interface PresignedUrlResponse {
9
+ uploadUrl: string;
10
+ publicUrl: string;
11
+ }
12
+
13
+ export default function ProfilePictureUpload() {
14
+ const { user } = useAuth();
15
+ const [isUploading, setIsUploading] = useState(false);
16
+ const [error, setError] = useState("");
17
+ const fileInputRef = useRef<HTMLInputElement>(null);
18
+
19
+ const handleFileSelect = async (file: File) => {
20
+ setError("");
21
+
22
+ if (file.size > MAX_FILE_SIZE) {
23
+ setError("File size must be less than 5MB.");
24
+ return;
25
+ }
26
+
27
+ if (!file.type.startsWith("image/")) {
28
+ setError("Please select an image file.");
29
+ return;
30
+ }
31
+
32
+ setIsUploading(true);
33
+
34
+ try {
35
+ // Get presigned upload URL
36
+ const { uploadUrl, publicUrl } = await apiPost<PresignedUrlResponse>(
37
+ "/api/user/profile/avatar/upload-url",
38
+ {
39
+ contentType: file.type,
40
+ fileName: file.name,
41
+ },
42
+ );
43
+
44
+ // Upload file to R2
45
+ await fetch(uploadUrl, {
46
+ method: "PUT",
47
+ headers: { "Content-Type": file.type },
48
+ body: file,
49
+ });
50
+
51
+ // Update profile with new image URL
52
+ await apiPatch("/api/user/profile", { image: publicUrl });
53
+
54
+ // Force page reload to show new avatar
55
+ window.location.reload();
56
+ } catch (err) {
57
+ setError(
58
+ err instanceof Error ? err.message : "Failed to upload image.",
59
+ );
60
+ } finally {
61
+ setIsUploading(false);
62
+ }
63
+ };
64
+
65
+ const handleClick = async () => {
66
+ if (isNative) {
67
+ try {
68
+ const { Camera, CameraResultType, CameraSource } = await import(
69
+ "@capacitor/camera"
70
+ );
71
+ const photo = await Camera.getPhoto({
72
+ quality: 80,
73
+ allowEditing: true,
74
+ resultType: CameraResultType.Uri,
75
+ source: CameraSource.Prompt,
76
+ width: 512,
77
+ height: 512,
78
+ });
79
+
80
+ if (photo.webPath) {
81
+ const response = await fetch(photo.webPath);
82
+ const blob = await response.blob();
83
+ const file = new File([blob], "avatar.jpg", {
84
+ type: `image/${photo.format}`,
85
+ });
86
+ await handleFileSelect(file);
87
+ }
88
+ } catch {
89
+ // User cancelled
90
+ }
91
+ } else {
92
+ fileInputRef.current?.click();
93
+ }
94
+ };
95
+
96
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
97
+ const file = e.target.files?.[0];
98
+ if (file) {
99
+ handleFileSelect(file);
100
+ }
101
+ };
102
+
103
+ const avatarUrl = user?.image;
104
+ const initials = user?.name?.charAt(0)?.toUpperCase() || "U";
105
+
106
+ return (
107
+ <div className="flex flex-col items-center gap-3">
108
+ <button
109
+ onClick={handleClick}
110
+ disabled={isUploading}
111
+ className="group relative h-20 w-20 overflow-hidden rounded-full border-2 border-keel-gray-800 transition-colors hover:border-keel-blue disabled:opacity-50"
112
+ >
113
+ {avatarUrl ? (
114
+ <img
115
+ src={avatarUrl}
116
+ alt={user?.name || "Avatar"}
117
+ className="h-full w-full object-cover"
118
+ />
119
+ ) : (
120
+ <div className="flex h-full w-full items-center justify-center bg-keel-blue/20 text-2xl font-semibold text-keel-blue">
121
+ {initials}
122
+ </div>
123
+ )}
124
+
125
+ <div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
126
+ {isUploading ? (
127
+ <div className="h-6 w-6 animate-spin rounded-full border-2 border-white/30 border-t-white" />
128
+ ) : (
129
+ <svg
130
+ className="h-6 w-6 text-white"
131
+ fill="none"
132
+ viewBox="0 0 24 24"
133
+ stroke="currentColor"
134
+ >
135
+ <path
136
+ strokeLinecap="round"
137
+ strokeLinejoin="round"
138
+ strokeWidth={2}
139
+ d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
140
+ />
141
+ <path
142
+ strokeLinecap="round"
143
+ strokeLinejoin="round"
144
+ strokeWidth={2}
145
+ d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
146
+ />
147
+ </svg>
148
+ )}
149
+ </div>
150
+ </button>
151
+
152
+ <input
153
+ ref={fileInputRef}
154
+ type="file"
155
+ accept="image/*"
156
+ onChange={handleInputChange}
157
+ className="hidden"
158
+ />
159
+
160
+ <p className="text-xs text-keel-gray-400">
161
+ {isUploading ? "Uploading..." : "Click to change"}
162
+ </p>
163
+
164
+ {error && <p className="text-xs text-red-400">{error}</p>}
165
+ </div>
166
+ );
167
+ }
@@ -0,0 +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); });