@codaijs/keel 0.2.2 → 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,384 +1,384 @@
1
- /**
2
- * Push Notifications Sail Installer
3
- *
4
- * Adds push notification support via Capacitor + Firebase Cloud Messaging.
5
- * Includes device token registration, server-side sending with firebase-admin,
6
- * and a React hook for managing notification lifecycle.
7
- *
8
- * Usage:
9
- * npx tsx sails/push-notifications/install.ts
10
- */
11
-
12
- import {
13
- readFileSync,
14
- writeFileSync,
15
- copyFileSync,
16
- existsSync,
17
- mkdirSync,
18
- } from "node:fs";
19
- import { resolve, dirname, join } from "node:path";
20
- import { execSync } from "node:child_process";
21
- import { input, confirm } from "@inquirer/prompts";
22
-
23
- // ---------------------------------------------------------------------------
24
- // Paths
25
- // ---------------------------------------------------------------------------
26
-
27
- const SAIL_DIR = dirname(new URL(import.meta.url).pathname);
28
- const PROJECT_ROOT = resolve(SAIL_DIR, "../..");
29
- const BACKEND_ROOT = join(PROJECT_ROOT, "packages/backend");
30
- const FRONTEND_ROOT = join(PROJECT_ROOT, "packages/frontend");
31
-
32
- // ---------------------------------------------------------------------------
33
- // Helpers
34
- // ---------------------------------------------------------------------------
35
-
36
- interface SailManifest {
37
- name: string;
38
- displayName: string;
39
- version: string;
40
- requiredEnvVars: { key: string; description: string }[];
41
- dependencies: { backend: Record<string, string>; frontend: Record<string, string> };
42
- }
43
-
44
- function loadManifest(): SailManifest {
45
- return JSON.parse(readFileSync(join(SAIL_DIR, "addon.json"), "utf-8"));
46
- }
47
-
48
- function insertAtMarker(filePath: string, marker: string, code: string): void {
49
- if (!existsSync(filePath)) {
50
- console.warn(` Warning: File not found: ${filePath}`);
51
- return;
52
- }
53
- let content = readFileSync(filePath, "utf-8");
54
- if (!content.includes(marker)) {
55
- console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
56
- return;
57
- }
58
- if (content.includes(code.trim())) {
59
- console.log(` Skipped (already present) -> ${filePath}`);
60
- return;
61
- }
62
- content = content.replace(marker, `${marker}\n${code}`);
63
- writeFileSync(filePath, content, "utf-8");
64
- console.log(` Modified -> ${filePath}`);
65
- }
66
-
67
- function copyFile(src: string, dest: string, label: string): void {
68
- mkdirSync(dirname(dest), { recursive: true });
69
- copyFileSync(src, dest);
70
- console.log(` Copied -> ${label}`);
71
- }
72
-
73
- function appendToEnvFiles(section: string, entries: Record<string, string>): void {
74
- for (const envFile of [".env.example", ".env"]) {
75
- const envPath = join(PROJECT_ROOT, envFile);
76
- if (!existsSync(envPath)) continue;
77
- let content = readFileSync(envPath, "utf-8");
78
- const lines: string[] = [];
79
- for (const [key, val] of Object.entries(entries)) {
80
- if (!content.includes(key)) lines.push(`${key}=${val}`);
81
- }
82
- if (lines.length > 0) {
83
- content += `\n# ${section}\n${lines.join("\n")}\n`;
84
- writeFileSync(envPath, content, "utf-8");
85
- console.log(` Updated ${envFile}`);
86
- }
87
- }
88
- }
89
-
90
- function installDeps(deps: Record<string, string>, workspace: string): void {
91
- const entries = Object.entries(deps);
92
- if (entries.length === 0) return;
93
- const packages = entries.map(([n, v]) => `${n}@${v}`).join(" ");
94
- const cmd = `npm install ${packages} --workspace=${workspace}`;
95
- console.log(` Running: ${cmd}`);
96
- execSync(cmd, { cwd: PROJECT_ROOT, stdio: "inherit" });
97
- }
98
-
99
- // ---------------------------------------------------------------------------
100
- // Main
101
- // ---------------------------------------------------------------------------
102
-
103
- async function main(): Promise<void> {
104
- const manifest = loadManifest();
105
-
106
- // -- Step 1: Welcome --------------------------------------------------------
107
- console.log("\n------------------------------------------------------------");
108
- console.log(` Push Notifications Sail Installer (v${manifest.version})`);
109
- console.log("------------------------------------------------------------");
110
- console.log();
111
- console.log(" This sail adds push notification support to your app:");
112
- console.log(" - Firebase Cloud Messaging (FCM) for delivery");
113
- console.log(" - Capacitor integration for native iOS/Android");
114
- console.log(" - Device token registration and management");
115
- console.log(" - Server-side notification sending with firebase-admin");
116
- console.log(" - React hook for permission handling and token lifecycle");
117
- console.log();
118
-
119
- const pkgPath = join(PROJECT_ROOT, "package.json");
120
- if (existsSync(pkgPath)) {
121
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
122
- console.log(` Template version: ${pkg.version ?? "unknown"}`);
123
- console.log();
124
- }
125
-
126
- // -- Step 2: Firebase setup guide -------------------------------------------
127
- const hasFirebase = await confirm({
128
- message: "Do you already have a Firebase project with Cloud Messaging enabled?",
129
- default: false,
130
- });
131
-
132
- if (!hasFirebase) {
133
- console.log();
134
- console.log(" Follow these steps to set up Firebase Cloud Messaging:");
135
- console.log();
136
- console.log(" 1. Go to https://console.firebase.google.com");
137
- console.log(" 2. Click 'Add project' (or select an existing project)");
138
- console.log(" 3. Follow the setup wizard (you can disable Google Analytics)");
139
- console.log(" 4. In the project, go to Project Settings > Cloud Messaging");
140
- console.log(" Make sure Cloud Messaging API (V1) is enabled");
141
- console.log(" 5. Go to Project Settings > Service Accounts");
142
- console.log(' 6. Click "Generate new private key" to download the JSON file');
143
- console.log(" 7. Open the JSON file — you will need:");
144
- console.log(" - project_id");
145
- console.log(" - private_key (the full PEM string)");
146
- console.log(" - client_email");
147
- console.log();
148
-
149
- await confirm({
150
- message: "I have my Firebase project set up and service account JSON ready",
151
- default: false,
152
- });
153
- }
154
-
155
- // -- Step 3: Collect credentials --------------------------------------------
156
- console.log();
157
- console.log(" Enter your Firebase service account credentials.");
158
- console.log(" These are found in the service account JSON file you downloaded.");
159
- console.log();
160
-
161
- const firebaseProjectId = await input({
162
- message: "Firebase Project ID (project_id):",
163
- validate: (value) => {
164
- if (!value || value.trim().length === 0) return "Project ID is required.";
165
- return true;
166
- },
167
- });
168
-
169
- const firebaseClientEmail = await input({
170
- message: "Firebase Client Email (client_email):",
171
- validate: (value) => {
172
- if (!value || value.trim().length === 0) return "Client email is required.";
173
- if (!value.includes("@")) return "Should be a valid service account email.";
174
- return true;
175
- },
176
- });
177
-
178
- console.log();
179
- console.log(" For the private key, paste the full PEM key from the JSON file.");
180
- console.log(" It starts with -----BEGIN PRIVATE KEY----- and ends with -----END PRIVATE KEY-----");
181
- console.log(" You can paste it as a single line with \\n for newlines.");
182
- console.log();
183
-
184
- const firebasePrivateKey = await input({
185
- message: "Firebase Private Key (private_key):",
186
- validate: (value) => {
187
- if (!value || value.trim().length === 0) return "Private key is required.";
188
- if (!value.includes("PRIVATE KEY")) return "Should contain 'PRIVATE KEY' — paste the full PEM key.";
189
- return true;
190
- },
191
- });
192
-
193
- // -- Step 4: Summary --------------------------------------------------------
194
- console.log();
195
- console.log(" Summary of changes:");
196
- console.log(" -------------------");
197
- console.log(" Files to copy (backend):");
198
- console.log(" + packages/backend/src/db/schema/notifications.ts");
199
- console.log(" + packages/backend/src/routes/notifications.ts");
200
- console.log(" + packages/backend/src/services/notifications.ts");
201
- console.log();
202
- console.log(" Files to copy (frontend):");
203
- console.log(" + packages/frontend/src/hooks/usePushNotifications.ts");
204
- console.log(" + packages/frontend/src/components/PushNotificationInit.tsx");
205
- console.log();
206
- console.log(" Files to modify:");
207
- console.log(" ~ packages/backend/src/db/schema/index.ts");
208
- console.log(" ~ packages/backend/src/index.ts");
209
- console.log(" ~ packages/backend/src/env.ts");
210
- console.log(" ~ packages/frontend/src/components/layout/Layout.tsx");
211
- console.log(" ~ .env.example / .env");
212
- console.log();
213
- console.log(" Environment variables:");
214
- console.log(` FIREBASE_PROJECT_ID=${firebaseProjectId}`);
215
- console.log(` FIREBASE_CLIENT_EMAIL=${firebaseClientEmail.slice(0, 20)}...`);
216
- console.log(` FIREBASE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----...`);
217
- console.log();
218
-
219
- // -- Step 5: Confirm --------------------------------------------------------
220
- const proceed = await confirm({ message: "Proceed with installation?", default: true });
221
- if (!proceed) { console.log("\n Installation cancelled.\n"); process.exit(0); }
222
-
223
- console.log();
224
- console.log(" Installing...");
225
- console.log();
226
-
227
- // -- Step 6: Copy files and insert at markers --------------------------------
228
- console.log(" Copying backend files...");
229
- const backendFiles = [
230
- { src: "backend/schema/notifications.ts", dest: "src/db/schema/notifications.ts" },
231
- { src: "backend/routes/notifications.ts", dest: "src/routes/notifications.ts" },
232
- { src: "backend/services/notifications.ts", dest: "src/services/notifications.ts" },
233
- ];
234
- for (const f of backendFiles) {
235
- copyFile(join(SAIL_DIR, "files", f.src), join(BACKEND_ROOT, f.dest), f.dest);
236
- }
237
-
238
- console.log();
239
- console.log(" Copying frontend files...");
240
- const frontendFiles = [
241
- { src: "frontend/hooks/usePushNotifications.ts", dest: "src/hooks/usePushNotifications.ts" },
242
- { src: "frontend/components/PushNotificationInit.tsx", dest: "src/components/PushNotificationInit.tsx" },
243
- ];
244
- for (const f of frontendFiles) {
245
- copyFile(join(SAIL_DIR, "files", f.src), join(FRONTEND_ROOT, f.dest), f.dest);
246
- }
247
-
248
- console.log();
249
- console.log(" Modifying backend files...");
250
-
251
- insertAtMarker(
252
- join(BACKEND_ROOT, "src/db/schema/index.ts"),
253
- "// [SAIL_SCHEMA]",
254
- 'export * from "./notifications.js";',
255
- );
256
-
257
- insertAtMarker(
258
- join(BACKEND_ROOT, "src/index.ts"),
259
- "// [SAIL_IMPORTS]",
260
- 'import { notificationsRouter } from "./routes/notifications.js";',
261
- );
262
-
263
- insertAtMarker(
264
- join(BACKEND_ROOT, "src/index.ts"),
265
- "// [SAIL_ROUTES]",
266
- 'app.use("/api/notifications", notificationsRouter);',
267
- );
268
-
269
- insertAtMarker(
270
- join(BACKEND_ROOT, "src/env.ts"),
271
- "// [SAIL_ENV_VARS]",
272
- ` FIREBASE_PROJECT_ID: z.string().min(1, "FIREBASE_PROJECT_ID is required"),\n FIREBASE_PRIVATE_KEY: z.string().min(1, "FIREBASE_PRIVATE_KEY is required"),\n FIREBASE_CLIENT_EMAIL: z.string().min(1, "FIREBASE_CLIENT_EMAIL is required"),`,
273
- );
274
-
275
- console.log();
276
- console.log(" Modifying frontend files...");
277
-
278
- // Add PushNotificationInit to Layout.tsx
279
- const layoutPath = join(FRONTEND_ROOT, "src/components/layout/Layout.tsx");
280
- if (existsSync(layoutPath)) {
281
- let layoutContent = readFileSync(layoutPath, "utf-8");
282
-
283
- // Add import if not present
284
- const importLine = 'import { PushNotificationInit } from "@/components/PushNotificationInit.js";';
285
- if (!layoutContent.includes("PushNotificationInit")) {
286
- // Insert import after the last import statement
287
- const lastImportIndex = layoutContent.lastIndexOf("import ");
288
- const lineEnd = layoutContent.indexOf("\n", lastImportIndex);
289
- layoutContent =
290
- layoutContent.slice(0, lineEnd + 1) +
291
- importLine + "\n" +
292
- layoutContent.slice(lineEnd + 1);
293
-
294
- // Insert <PushNotificationInit /> after useDeepLinks() call
295
- layoutContent = layoutContent.replace(
296
- "useDeepLinks();",
297
- "useDeepLinks();\n\n return (\n",
298
- );
299
-
300
- // Actually, let's just insert the component in the JSX
301
- // Revert the above — re-read the file cleanly
302
- layoutContent = readFileSync(layoutPath, "utf-8");
303
-
304
- // Add import at the top after last import
305
- const lastImport = layoutContent.lastIndexOf("import ");
306
- const importLineEnd = layoutContent.indexOf("\n", lastImport);
307
- layoutContent =
308
- layoutContent.slice(0, importLineEnd + 1) +
309
- importLine + "\n" +
310
- layoutContent.slice(importLineEnd + 1);
311
-
312
- // Add component in JSX — insert right after the opening <div>
313
- layoutContent = layoutContent.replace(
314
- '<div className="flex min-h-screen flex-col bg-keel-navy">',
315
- '<div className="flex min-h-screen flex-col bg-keel-navy">\n <PushNotificationInit />',
316
- );
317
-
318
- writeFileSync(layoutPath, layoutContent, "utf-8");
319
- console.log(` Modified -> Layout.tsx`);
320
- } else {
321
- console.log(` Skipped (already present) -> Layout.tsx`);
322
- }
323
- } else {
324
- console.warn(" Warning: Layout.tsx not found. Add <PushNotificationInit /> manually.");
325
- }
326
-
327
- console.log();
328
- console.log(" Installing dependencies...");
329
- installDeps(manifest.dependencies.backend, "packages/backend");
330
- installDeps(manifest.dependencies.frontend, "packages/frontend");
331
-
332
- console.log();
333
- console.log(" Generating database migrations...");
334
- try {
335
- execSync("npx drizzle-kit generate", { cwd: BACKEND_ROOT, stdio: "inherit" });
336
- } catch {
337
- console.warn(" Warning: Could not generate migrations. Run manually: cd packages/backend && npx drizzle-kit generate");
338
- }
339
-
340
- console.log();
341
- console.log(" Updating environment files...");
342
- appendToEnvFiles("Push Notifications (Firebase)", {
343
- FIREBASE_PROJECT_ID: firebaseProjectId,
344
- FIREBASE_PRIVATE_KEY: firebasePrivateKey,
345
- FIREBASE_CLIENT_EMAIL: firebaseClientEmail,
346
- });
347
-
348
- // -- Step 7: Next steps ------------------------------------------------------
349
- console.log();
350
- console.log("------------------------------------------------------------");
351
- console.log(" Push Notifications installed successfully!");
352
- console.log("------------------------------------------------------------");
353
- console.log();
354
- console.log(" Next steps:");
355
- console.log();
356
- console.log(" 1. Run database migrations:");
357
- console.log(" npm run db:migrate");
358
- console.log();
359
- console.log(" 2. Configure iOS (APNs):");
360
- console.log(" - Go to https://developer.apple.com/account/resources/authkeys/list");
361
- console.log(' - Create an APNs authentication key (check "Apple Push Notifications service")');
362
- console.log(" - Download the .p8 key file");
363
- console.log(" - In Firebase Console > Project Settings > Cloud Messaging > iOS:");
364
- console.log(" Upload the APNs key, enter Key ID and Team ID");
365
- console.log();
366
- console.log(" 3. Configure Android:");
367
- console.log(" - In Firebase Console, add an Android app to your project");
368
- console.log(" - Download google-services.json");
369
- console.log(" - Place it in your Android project: android/app/google-services.json");
370
- console.log();
371
- console.log(" 4. Sync Capacitor:");
372
- console.log(" npx cap sync");
373
- console.log();
374
- console.log(" 5. Start your dev server:");
375
- console.log(" npm run dev");
376
- console.log();
377
- console.log(" Testing push notifications:");
378
- console.log(" - Push notifications only work on physical devices (not simulators)");
379
- console.log(" - Use the Firebase Console > Messaging to send test notifications");
380
- console.log(" - Or use the POST /api/notifications/send endpoint from your backend");
381
- console.log();
382
- }
383
-
384
- main().catch((err) => { console.error("Installation failed:", err); process.exit(1); });
1
+ /**
2
+ * Push Notifications Sail Installer
3
+ *
4
+ * Adds push notification support via Capacitor + Firebase Cloud Messaging.
5
+ * Includes device token registration, server-side sending with firebase-admin,
6
+ * and a React hook for managing notification lifecycle.
7
+ *
8
+ * Usage:
9
+ * npx tsx sails/push-notifications/install.ts
10
+ */
11
+
12
+ import {
13
+ readFileSync,
14
+ writeFileSync,
15
+ copyFileSync,
16
+ existsSync,
17
+ mkdirSync,
18
+ } from "node:fs";
19
+ import { resolve, dirname, join } from "node:path";
20
+ import { execSync } from "node:child_process";
21
+ import { input, confirm } from "@inquirer/prompts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Paths
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const SAIL_DIR = dirname(new URL(import.meta.url).pathname);
28
+ const PROJECT_ROOT = resolve(SAIL_DIR, "../..");
29
+ const BACKEND_ROOT = join(PROJECT_ROOT, "packages/backend");
30
+ const FRONTEND_ROOT = join(PROJECT_ROOT, "packages/frontend");
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ interface SailManifest {
37
+ name: string;
38
+ displayName: string;
39
+ version: string;
40
+ requiredEnvVars: { key: string; description: string }[];
41
+ dependencies: { backend: Record<string, string>; frontend: Record<string, string> };
42
+ }
43
+
44
+ function loadManifest(): SailManifest {
45
+ return JSON.parse(readFileSync(join(SAIL_DIR, "addon.json"), "utf-8"));
46
+ }
47
+
48
+ function insertAtMarker(filePath: string, marker: string, code: string): void {
49
+ if (!existsSync(filePath)) {
50
+ console.warn(` Warning: File not found: ${filePath}`);
51
+ return;
52
+ }
53
+ let content = readFileSync(filePath, "utf-8");
54
+ if (!content.includes(marker)) {
55
+ console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
56
+ return;
57
+ }
58
+ if (content.includes(code.trim())) {
59
+ console.log(` Skipped (already present) -> ${filePath}`);
60
+ return;
61
+ }
62
+ content = content.replace(marker, `${marker}\n${code}`);
63
+ writeFileSync(filePath, content, "utf-8");
64
+ console.log(` Modified -> ${filePath}`);
65
+ }
66
+
67
+ function copyFile(src: string, dest: string, label: string): void {
68
+ mkdirSync(dirname(dest), { recursive: true });
69
+ copyFileSync(src, dest);
70
+ console.log(` Copied -> ${label}`);
71
+ }
72
+
73
+ function appendToEnvFiles(section: string, entries: Record<string, string>): void {
74
+ for (const envFile of [".env.example", ".env"]) {
75
+ const envPath = join(PROJECT_ROOT, envFile);
76
+ if (!existsSync(envPath)) continue;
77
+ let content = readFileSync(envPath, "utf-8");
78
+ const lines: string[] = [];
79
+ for (const [key, val] of Object.entries(entries)) {
80
+ if (!content.includes(key)) lines.push(`${key}=${val}`);
81
+ }
82
+ if (lines.length > 0) {
83
+ content += `\n# ${section}\n${lines.join("\n")}\n`;
84
+ writeFileSync(envPath, content, "utf-8");
85
+ console.log(` Updated ${envFile}`);
86
+ }
87
+ }
88
+ }
89
+
90
+ function installDeps(deps: Record<string, string>, workspace: string): void {
91
+ const entries = Object.entries(deps);
92
+ if (entries.length === 0) return;
93
+ const packages = entries.map(([n, v]) => `${n}@${v}`).join(" ");
94
+ const cmd = `npm install ${packages} --workspace=${workspace}`;
95
+ console.log(` Running: ${cmd}`);
96
+ execSync(cmd, { cwd: PROJECT_ROOT, stdio: "inherit" });
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Main
101
+ // ---------------------------------------------------------------------------
102
+
103
+ async function main(): Promise<void> {
104
+ const manifest = loadManifest();
105
+
106
+ // -- Step 1: Welcome --------------------------------------------------------
107
+ console.log("\n------------------------------------------------------------");
108
+ console.log(` Push Notifications Sail Installer (v${manifest.version})`);
109
+ console.log("------------------------------------------------------------");
110
+ console.log();
111
+ console.log(" This sail adds push notification support to your app:");
112
+ console.log(" - Firebase Cloud Messaging (FCM) for delivery");
113
+ console.log(" - Capacitor integration for native iOS/Android");
114
+ console.log(" - Device token registration and management");
115
+ console.log(" - Server-side notification sending with firebase-admin");
116
+ console.log(" - React hook for permission handling and token lifecycle");
117
+ console.log();
118
+
119
+ const pkgPath = join(PROJECT_ROOT, "package.json");
120
+ if (existsSync(pkgPath)) {
121
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
122
+ console.log(` Template version: ${pkg.version ?? "unknown"}`);
123
+ console.log();
124
+ }
125
+
126
+ // -- Step 2: Firebase setup guide -------------------------------------------
127
+ const hasFirebase = await confirm({
128
+ message: "Do you already have a Firebase project with Cloud Messaging enabled?",
129
+ default: false,
130
+ });
131
+
132
+ if (!hasFirebase) {
133
+ console.log();
134
+ console.log(" Follow these steps to set up Firebase Cloud Messaging:");
135
+ console.log();
136
+ console.log(" 1. Go to https://console.firebase.google.com");
137
+ console.log(" 2. Click 'Add project' (or select an existing project)");
138
+ console.log(" 3. Follow the setup wizard (you can disable Google Analytics)");
139
+ console.log(" 4. In the project, go to Project Settings > Cloud Messaging");
140
+ console.log(" Make sure Cloud Messaging API (V1) is enabled");
141
+ console.log(" 5. Go to Project Settings > Service Accounts");
142
+ console.log(' 6. Click "Generate new private key" to download the JSON file');
143
+ console.log(" 7. Open the JSON file — you will need:");
144
+ console.log(" - project_id");
145
+ console.log(" - private_key (the full PEM string)");
146
+ console.log(" - client_email");
147
+ console.log();
148
+
149
+ await confirm({
150
+ message: "I have my Firebase project set up and service account JSON ready",
151
+ default: false,
152
+ });
153
+ }
154
+
155
+ // -- Step 3: Collect credentials --------------------------------------------
156
+ console.log();
157
+ console.log(" Enter your Firebase service account credentials.");
158
+ console.log(" These are found in the service account JSON file you downloaded.");
159
+ console.log();
160
+
161
+ const firebaseProjectId = await input({
162
+ message: "Firebase Project ID (project_id):",
163
+ validate: (value) => {
164
+ if (!value || value.trim().length === 0) return "Project ID is required.";
165
+ return true;
166
+ },
167
+ });
168
+
169
+ const firebaseClientEmail = await input({
170
+ message: "Firebase Client Email (client_email):",
171
+ validate: (value) => {
172
+ if (!value || value.trim().length === 0) return "Client email is required.";
173
+ if (!value.includes("@")) return "Should be a valid service account email.";
174
+ return true;
175
+ },
176
+ });
177
+
178
+ console.log();
179
+ console.log(" For the private key, paste the full PEM key from the JSON file.");
180
+ console.log(" It starts with -----BEGIN PRIVATE KEY----- and ends with -----END PRIVATE KEY-----");
181
+ console.log(" You can paste it as a single line with \\n for newlines.");
182
+ console.log();
183
+
184
+ const firebasePrivateKey = await input({
185
+ message: "Firebase Private Key (private_key):",
186
+ validate: (value) => {
187
+ if (!value || value.trim().length === 0) return "Private key is required.";
188
+ if (!value.includes("PRIVATE KEY")) return "Should contain 'PRIVATE KEY' — paste the full PEM key.";
189
+ return true;
190
+ },
191
+ });
192
+
193
+ // -- Step 4: Summary --------------------------------------------------------
194
+ console.log();
195
+ console.log(" Summary of changes:");
196
+ console.log(" -------------------");
197
+ console.log(" Files to copy (backend):");
198
+ console.log(" + packages/backend/src/db/schema/notifications.ts");
199
+ console.log(" + packages/backend/src/routes/notifications.ts");
200
+ console.log(" + packages/backend/src/services/notifications.ts");
201
+ console.log();
202
+ console.log(" Files to copy (frontend):");
203
+ console.log(" + packages/frontend/src/hooks/usePushNotifications.ts");
204
+ console.log(" + packages/frontend/src/components/PushNotificationInit.tsx");
205
+ console.log();
206
+ console.log(" Files to modify:");
207
+ console.log(" ~ packages/backend/src/db/schema/index.ts");
208
+ console.log(" ~ packages/backend/src/index.ts");
209
+ console.log(" ~ packages/backend/src/env.ts");
210
+ console.log(" ~ packages/frontend/src/components/layout/Layout.tsx");
211
+ console.log(" ~ .env.example / .env");
212
+ console.log();
213
+ console.log(" Environment variables:");
214
+ console.log(` FIREBASE_PROJECT_ID=${firebaseProjectId}`);
215
+ console.log(` FIREBASE_CLIENT_EMAIL=${firebaseClientEmail.slice(0, 20)}...`);
216
+ console.log(` FIREBASE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----...`);
217
+ console.log();
218
+
219
+ // -- Step 5: Confirm --------------------------------------------------------
220
+ const proceed = await confirm({ message: "Proceed with installation?", default: true });
221
+ if (!proceed) { console.log("\n Installation cancelled.\n"); process.exit(0); }
222
+
223
+ console.log();
224
+ console.log(" Installing...");
225
+ console.log();
226
+
227
+ // -- Step 6: Copy files and insert at markers --------------------------------
228
+ console.log(" Copying backend files...");
229
+ const backendFiles = [
230
+ { src: "backend/schema/notifications.ts", dest: "src/db/schema/notifications.ts" },
231
+ { src: "backend/routes/notifications.ts", dest: "src/routes/notifications.ts" },
232
+ { src: "backend/services/notifications.ts", dest: "src/services/notifications.ts" },
233
+ ];
234
+ for (const f of backendFiles) {
235
+ copyFile(join(SAIL_DIR, "files", f.src), join(BACKEND_ROOT, f.dest), f.dest);
236
+ }
237
+
238
+ console.log();
239
+ console.log(" Copying frontend files...");
240
+ const frontendFiles = [
241
+ { src: "frontend/hooks/usePushNotifications.ts", dest: "src/hooks/usePushNotifications.ts" },
242
+ { src: "frontend/components/PushNotificationInit.tsx", dest: "src/components/PushNotificationInit.tsx" },
243
+ ];
244
+ for (const f of frontendFiles) {
245
+ copyFile(join(SAIL_DIR, "files", f.src), join(FRONTEND_ROOT, f.dest), f.dest);
246
+ }
247
+
248
+ console.log();
249
+ console.log(" Modifying backend files...");
250
+
251
+ insertAtMarker(
252
+ join(BACKEND_ROOT, "src/db/schema/index.ts"),
253
+ "// [SAIL_SCHEMA]",
254
+ 'export * from "./notifications.js";',
255
+ );
256
+
257
+ insertAtMarker(
258
+ join(BACKEND_ROOT, "src/index.ts"),
259
+ "// [SAIL_IMPORTS]",
260
+ 'import { notificationsRouter } from "./routes/notifications.js";',
261
+ );
262
+
263
+ insertAtMarker(
264
+ join(BACKEND_ROOT, "src/index.ts"),
265
+ "// [SAIL_ROUTES]",
266
+ 'app.use("/api/notifications", notificationsRouter);',
267
+ );
268
+
269
+ insertAtMarker(
270
+ join(BACKEND_ROOT, "src/env.ts"),
271
+ "// [SAIL_ENV_VARS]",
272
+ ` FIREBASE_PROJECT_ID: z.string().min(1, "FIREBASE_PROJECT_ID is required"),\n FIREBASE_PRIVATE_KEY: z.string().min(1, "FIREBASE_PRIVATE_KEY is required"),\n FIREBASE_CLIENT_EMAIL: z.string().min(1, "FIREBASE_CLIENT_EMAIL is required"),`,
273
+ );
274
+
275
+ console.log();
276
+ console.log(" Modifying frontend files...");
277
+
278
+ // Add PushNotificationInit to Layout.tsx
279
+ const layoutPath = join(FRONTEND_ROOT, "src/components/layout/Layout.tsx");
280
+ if (existsSync(layoutPath)) {
281
+ let layoutContent = readFileSync(layoutPath, "utf-8");
282
+
283
+ // Add import if not present
284
+ const importLine = 'import { PushNotificationInit } from "@/components/PushNotificationInit.js";';
285
+ if (!layoutContent.includes("PushNotificationInit")) {
286
+ // Insert import after the last import statement
287
+ const lastImportIndex = layoutContent.lastIndexOf("import ");
288
+ const lineEnd = layoutContent.indexOf("\n", lastImportIndex);
289
+ layoutContent =
290
+ layoutContent.slice(0, lineEnd + 1) +
291
+ importLine + "\n" +
292
+ layoutContent.slice(lineEnd + 1);
293
+
294
+ // Insert <PushNotificationInit /> after useDeepLinks() call
295
+ layoutContent = layoutContent.replace(
296
+ "useDeepLinks();",
297
+ "useDeepLinks();\n\n return (\n",
298
+ );
299
+
300
+ // Actually, let's just insert the component in the JSX
301
+ // Revert the above — re-read the file cleanly
302
+ layoutContent = readFileSync(layoutPath, "utf-8");
303
+
304
+ // Add import at the top after last import
305
+ const lastImport = layoutContent.lastIndexOf("import ");
306
+ const importLineEnd = layoutContent.indexOf("\n", lastImport);
307
+ layoutContent =
308
+ layoutContent.slice(0, importLineEnd + 1) +
309
+ importLine + "\n" +
310
+ layoutContent.slice(importLineEnd + 1);
311
+
312
+ // Add component in JSX — insert right after the opening <div>
313
+ layoutContent = layoutContent.replace(
314
+ '<div className="flex min-h-screen flex-col bg-keel-navy">',
315
+ '<div className="flex min-h-screen flex-col bg-keel-navy">\n <PushNotificationInit />',
316
+ );
317
+
318
+ writeFileSync(layoutPath, layoutContent, "utf-8");
319
+ console.log(` Modified -> Layout.tsx`);
320
+ } else {
321
+ console.log(` Skipped (already present) -> Layout.tsx`);
322
+ }
323
+ } else {
324
+ console.warn(" Warning: Layout.tsx not found. Add <PushNotificationInit /> manually.");
325
+ }
326
+
327
+ console.log();
328
+ console.log(" Installing dependencies...");
329
+ installDeps(manifest.dependencies.backend, "packages/backend");
330
+ installDeps(manifest.dependencies.frontend, "packages/frontend");
331
+
332
+ console.log();
333
+ console.log(" Generating database migrations...");
334
+ try {
335
+ execSync("npx drizzle-kit generate", { cwd: BACKEND_ROOT, stdio: "inherit" });
336
+ } catch {
337
+ console.warn(" Warning: Could not generate migrations. Run manually: cd packages/backend && npx drizzle-kit generate");
338
+ }
339
+
340
+ console.log();
341
+ console.log(" Updating environment files...");
342
+ appendToEnvFiles("Push Notifications (Firebase)", {
343
+ FIREBASE_PROJECT_ID: firebaseProjectId,
344
+ FIREBASE_PRIVATE_KEY: firebasePrivateKey,
345
+ FIREBASE_CLIENT_EMAIL: firebaseClientEmail,
346
+ });
347
+
348
+ // -- Step 7: Next steps ------------------------------------------------------
349
+ console.log();
350
+ console.log("------------------------------------------------------------");
351
+ console.log(" Push Notifications installed successfully!");
352
+ console.log("------------------------------------------------------------");
353
+ console.log();
354
+ console.log(" Next steps:");
355
+ console.log();
356
+ console.log(" 1. Run database migrations:");
357
+ console.log(" npm run db:migrate");
358
+ console.log();
359
+ console.log(" 2. Configure iOS (APNs):");
360
+ console.log(" - Go to https://developer.apple.com/account/resources/authkeys/list");
361
+ console.log(' - Create an APNs authentication key (check "Apple Push Notifications service")');
362
+ console.log(" - Download the .p8 key file");
363
+ console.log(" - In Firebase Console > Project Settings > Cloud Messaging > iOS:");
364
+ console.log(" Upload the APNs key, enter Key ID and Team ID");
365
+ console.log();
366
+ console.log(" 3. Configure Android:");
367
+ console.log(" - In Firebase Console, add an Android app to your project");
368
+ console.log(" - Download google-services.json");
369
+ console.log(" - Place it in your Android project: android/app/google-services.json");
370
+ console.log();
371
+ console.log(" 4. Sync Capacitor:");
372
+ console.log(" npx cap sync");
373
+ console.log();
374
+ console.log(" 5. Start your dev server:");
375
+ console.log(" npm run dev");
376
+ console.log();
377
+ console.log(" Testing push notifications:");
378
+ console.log(" - Push notifications only work on physical devices (not simulators)");
379
+ console.log(" - Use the Firebase Console > Messaging to send test notifications");
380
+ console.log(" - Or use the POST /api/notifications/send endpoint from your backend");
381
+ console.log();
382
+ }
383
+
384
+ main().catch((err) => { console.error("Installation failed:", err); process.exit(1); });