create-crm-starter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/index.js +2341 -0
- package/package.json +51 -0
- package/template/base/_dot_gitignore +11 -0
- package/template/base/eslint.config.mjs +7 -0
- package/template/base/next.config.ts +17 -0
- package/template/base/package.json +35 -0
- package/template/base/postcss.config.mjs +7 -0
- package/template/base/scripts/setup.mjs +146 -0
- package/template/base/src/app/globals.css +68 -0
- package/template/base/src/app/layout.tsx +19 -0
- package/template/base/src/app/page.tsx +37 -0
- package/template/base/src/components/ui/badge.tsx +29 -0
- package/template/base/src/components/ui/button.tsx +49 -0
- package/template/base/src/components/ui/card.tsx +54 -0
- package/template/base/src/components/ui/input.tsx +19 -0
- package/template/base/src/components/ui/label.tsx +19 -0
- package/template/base/src/components/ui/separator.tsx +25 -0
- package/template/base/src/components/ui/skeleton.tsx +7 -0
- package/template/base/src/lib/utils.ts +17 -0
- package/template/base/tsconfig.json +21 -0
- package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
- package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
- package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
- package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
- package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
- package/template/extras/auth-better-auth/src/middleware.ts +25 -0
- package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
- package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
- package/template/extras/auth-clerk/src/middleware.ts +17 -0
- package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
- package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
- package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
- package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
- package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
- package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
- package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
- package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
- package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
- package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
- package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
- package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
- package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
- package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
- package/template/extras/customers/_fragments/convex.txt +28 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
- package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
- package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
- package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
- package/template/extras/customers/convex/convex/customers.ts +52 -0
- package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
- package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
- package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
- package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
- package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
- package/template/extras/db-convex/convex/_generated/README.md +13 -0
- package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
- package/template/extras/db-convex/convex/_generated/api.js +12 -0
- package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
- package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
- package/template/extras/db-convex/convex/_generated/server.js +12 -0
- package/template/extras/db-convex/convex/auth.config.ts +17 -0
- package/template/extras/db-convex/convex/schema.ts +28 -0
- package/template/extras/db-convex/src/app/layout.tsx +20 -0
- package/template/extras/db-convex/src/components/providers.tsx +28 -0
- package/template/extras/db-convex/src/lib/convex.ts +6 -0
- package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
- package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
- package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
- package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
- package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
- package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
- package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
- package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
- package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
- package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
- package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
- package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
- package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
- package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
- package/template/extras/jobs/_fragments/convex.txt +21 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
- package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
- package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
- package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
- package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
- package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
- package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
- package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
- package/template/extras/jobs/convex/convex/jobs.ts +46 -0
- package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
- package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
- package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
- package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
- package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
- package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
- package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
- package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
- package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
- package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
- package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
- package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
- package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
- package/template/extras/mobile/mobile/README.md +67 -0
- package/template/extras/mobile/mobile/_dot_env.example +5 -0
- package/template/extras/mobile/mobile/_dot_gitignore +26 -0
- package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
- package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
- package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
- package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
- package/template/extras/mobile/mobile/app/index.tsx +23 -0
- package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
- package/template/extras/mobile/mobile/app.brand.ts +14 -0
- package/template/extras/mobile/mobile/app.config.ts +40 -0
- package/template/extras/mobile/mobile/app.features.ts +11 -0
- package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
- package/template/extras/mobile/mobile/eas.json +22 -0
- package/template/extras/mobile/mobile/lib/api.ts +253 -0
- package/template/extras/mobile/mobile/lib/auth.ts +51 -0
- package/template/extras/mobile/mobile/lib/format.ts +23 -0
- package/template/extras/mobile/mobile/lib/push.ts +24 -0
- package/template/extras/mobile/mobile/lib/theme.ts +16 -0
- package/template/extras/mobile/mobile/package.json +34 -0
- package/template/extras/mobile/mobile/tsconfig.json +11 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
- package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
- package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
- package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
- package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
- package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
- package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
- package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
- package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
- package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
- package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
- package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
- package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
- package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
- package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
- package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
- package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
- package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
- package/template/extras/seed/_shared/scripts/seed.ts +35 -0
- package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
- package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
- package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
- package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
- package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/data.ts +124 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { defineCommand, runMain } from "citty";
|
|
5
|
+
|
|
6
|
+
// src/commands/create.ts
|
|
7
|
+
import path21 from "path";
|
|
8
|
+
import fs16 from "fs/promises";
|
|
9
|
+
import { intro, log, note as note3, outro, spinner as spinner2 } from "@clack/prompts";
|
|
10
|
+
import pc3 from "picocolors";
|
|
11
|
+
|
|
12
|
+
// src/prompts/runPrompts.ts
|
|
13
|
+
import path from "path";
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import {
|
|
16
|
+
cancel,
|
|
17
|
+
confirm,
|
|
18
|
+
group,
|
|
19
|
+
isCancel,
|
|
20
|
+
multiselect,
|
|
21
|
+
note,
|
|
22
|
+
select,
|
|
23
|
+
text
|
|
24
|
+
} from "@clack/prompts";
|
|
25
|
+
import pc from "picocolors";
|
|
26
|
+
|
|
27
|
+
// src/prompts/industries/general.ts
|
|
28
|
+
var general = {
|
|
29
|
+
id: "general",
|
|
30
|
+
label: "General home services",
|
|
31
|
+
primaryColor: "#0ea5e9",
|
|
32
|
+
// sky-500
|
|
33
|
+
accentColor: "#f59e0b",
|
|
34
|
+
// amber-500
|
|
35
|
+
sampleTagline: "Trusted home-service pros at your door.",
|
|
36
|
+
serviceTypes: ["Service Call", "Estimate", "Maintenance", "Repair", "Install"],
|
|
37
|
+
defaults: {
|
|
38
|
+
landingPage: true,
|
|
39
|
+
onlineBooking: true,
|
|
40
|
+
stack: "clerk-convex",
|
|
41
|
+
roles: ["admin", "dispatcher", "technician", "csr"],
|
|
42
|
+
customers: true,
|
|
43
|
+
jobs: true,
|
|
44
|
+
estimatesInvoices: true,
|
|
45
|
+
calendarDispatch: true,
|
|
46
|
+
payments: true,
|
|
47
|
+
comms: ["sms", "email"],
|
|
48
|
+
reviews: true,
|
|
49
|
+
reporting: true,
|
|
50
|
+
seed: true,
|
|
51
|
+
initGit: true,
|
|
52
|
+
install: true,
|
|
53
|
+
deployTarget: "vercel"
|
|
54
|
+
},
|
|
55
|
+
extraModules: [],
|
|
56
|
+
seedFixtures: "general"
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/prompts/industries/hvac.ts
|
|
60
|
+
var hvac = {
|
|
61
|
+
id: "hvac",
|
|
62
|
+
label: "HVAC",
|
|
63
|
+
primaryColor: "#ea580c",
|
|
64
|
+
// orange-600
|
|
65
|
+
accentColor: "#0284c7",
|
|
66
|
+
// sky-600 (cool counterpoint to hot orange)
|
|
67
|
+
sampleTagline: "Heating & cooling done right, on time, the first time.",
|
|
68
|
+
serviceTypes: ["Install", "Repair", "Maintenance", "Tune-up", "Diagnostic", "Refrigerant Recharge"],
|
|
69
|
+
defaults: {
|
|
70
|
+
landingPage: true,
|
|
71
|
+
onlineBooking: true,
|
|
72
|
+
stack: "clerk-convex",
|
|
73
|
+
roles: ["admin", "dispatcher", "technician", "csr"],
|
|
74
|
+
customers: true,
|
|
75
|
+
jobs: true,
|
|
76
|
+
estimatesInvoices: true,
|
|
77
|
+
calendarDispatch: true,
|
|
78
|
+
payments: true,
|
|
79
|
+
comms: ["sms", "email"],
|
|
80
|
+
reviews: true,
|
|
81
|
+
reporting: true,
|
|
82
|
+
seed: true,
|
|
83
|
+
initGit: true,
|
|
84
|
+
install: true,
|
|
85
|
+
deployTarget: "vercel"
|
|
86
|
+
},
|
|
87
|
+
extraModules: ["equipment-tracking", "maintenance-plans"],
|
|
88
|
+
seedFixtures: "hvac"
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// src/prompts/industries/plumbing.ts
|
|
92
|
+
var plumbing = {
|
|
93
|
+
id: "plumbing",
|
|
94
|
+
label: "Plumbing",
|
|
95
|
+
primaryColor: "#2563eb",
|
|
96
|
+
// blue-600
|
|
97
|
+
accentColor: "#dc2626",
|
|
98
|
+
// red-600 (emergency / urgent counterpoint)
|
|
99
|
+
sampleTagline: "Fast, honest plumbing. Available 24/7.",
|
|
100
|
+
serviceTypes: ["Service Call", "Drain Cleaning", "Leak Repair", "Water Heater", "Re-pipe", "Emergency"],
|
|
101
|
+
defaults: {
|
|
102
|
+
landingPage: true,
|
|
103
|
+
onlineBooking: true,
|
|
104
|
+
stack: "clerk-convex",
|
|
105
|
+
roles: ["admin", "dispatcher", "technician", "csr"],
|
|
106
|
+
customers: true,
|
|
107
|
+
jobs: true,
|
|
108
|
+
estimatesInvoices: true,
|
|
109
|
+
calendarDispatch: true,
|
|
110
|
+
payments: true,
|
|
111
|
+
comms: ["sms", "email"],
|
|
112
|
+
reviews: true,
|
|
113
|
+
reporting: true,
|
|
114
|
+
seed: true,
|
|
115
|
+
initGit: true,
|
|
116
|
+
install: true,
|
|
117
|
+
deployTarget: "vercel"
|
|
118
|
+
},
|
|
119
|
+
extraModules: ["emergency-dispatch", "permit-tracking"],
|
|
120
|
+
seedFixtures: "plumbing"
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// src/prompts/industries/electrical.ts
|
|
124
|
+
var electrical = {
|
|
125
|
+
id: "electrical",
|
|
126
|
+
label: "Electrical",
|
|
127
|
+
primaryColor: "#eab308",
|
|
128
|
+
// yellow-500 (electricity / caution)
|
|
129
|
+
accentColor: "#0f172a",
|
|
130
|
+
// slate-900 (high contrast for wiring/danger feel)
|
|
131
|
+
sampleTagline: "Licensed electricians. Code-correct. Code-fast.",
|
|
132
|
+
serviceTypes: ["Service Call", "Panel Upgrade", "Outlet/Switch", "Lighting", "EV Charger", "Generator", "Inspection"],
|
|
133
|
+
defaults: {
|
|
134
|
+
landingPage: true,
|
|
135
|
+
onlineBooking: true,
|
|
136
|
+
stack: "clerk-convex",
|
|
137
|
+
roles: ["admin", "dispatcher", "technician", "csr"],
|
|
138
|
+
customers: true,
|
|
139
|
+
jobs: true,
|
|
140
|
+
estimatesInvoices: true,
|
|
141
|
+
calendarDispatch: true,
|
|
142
|
+
payments: true,
|
|
143
|
+
comms: ["sms", "email"],
|
|
144
|
+
reviews: true,
|
|
145
|
+
reporting: true,
|
|
146
|
+
seed: true,
|
|
147
|
+
initGit: true,
|
|
148
|
+
install: true,
|
|
149
|
+
deployTarget: "vercel"
|
|
150
|
+
},
|
|
151
|
+
extraModules: ["permit-tracking", "inspection-checklists"],
|
|
152
|
+
seedFixtures: "electrical"
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// src/prompts/industries/cleaning.ts
|
|
156
|
+
var cleaning = {
|
|
157
|
+
id: "cleaning",
|
|
158
|
+
label: "Home / Commercial Cleaning",
|
|
159
|
+
primaryColor: "#7c3aed",
|
|
160
|
+
// violet-600
|
|
161
|
+
accentColor: "#06b6d4",
|
|
162
|
+
// cyan-500
|
|
163
|
+
sampleTagline: "Spotless homes. Sparkling reviews.",
|
|
164
|
+
serviceTypes: ["Standard Cleaning", "Deep Clean", "Move-in/Move-out", "Recurring (Weekly)", "Recurring (Bi-weekly)", "Post-Construction"],
|
|
165
|
+
defaults: {
|
|
166
|
+
landingPage: true,
|
|
167
|
+
onlineBooking: true,
|
|
168
|
+
stack: "clerk-convex",
|
|
169
|
+
roles: ["admin", "dispatcher", "technician", "csr"],
|
|
170
|
+
customers: true,
|
|
171
|
+
jobs: true,
|
|
172
|
+
estimatesInvoices: true,
|
|
173
|
+
calendarDispatch: true,
|
|
174
|
+
payments: true,
|
|
175
|
+
comms: ["sms", "email"],
|
|
176
|
+
reviews: true,
|
|
177
|
+
reporting: true,
|
|
178
|
+
seed: true,
|
|
179
|
+
initGit: true,
|
|
180
|
+
install: true,
|
|
181
|
+
deployTarget: "vercel"
|
|
182
|
+
},
|
|
183
|
+
// Recurring service plans + crew-style dispatch (multi-tech assignment) are the
|
|
184
|
+
// cleaning-specific add-ons (Phase 18 builds these out).
|
|
185
|
+
extraModules: ["maintenance-plans"],
|
|
186
|
+
seedFixtures: "cleaning"
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// src/prompts/industries/landscaping.ts
|
|
190
|
+
var landscaping = {
|
|
191
|
+
id: "landscaping",
|
|
192
|
+
label: "Landscaping / Lawn Care",
|
|
193
|
+
primaryColor: "#16a34a",
|
|
194
|
+
// green-600
|
|
195
|
+
accentColor: "#ca8a04",
|
|
196
|
+
// yellow-600
|
|
197
|
+
sampleTagline: "Lawns done right. Yards transformed.",
|
|
198
|
+
serviceTypes: ["Mowing (weekly)", "Mowing (bi-weekly)", "Cleanup", "Mulching", "Pruning", "Sod Install", "Hardscape Project"],
|
|
199
|
+
defaults: {
|
|
200
|
+
landingPage: true,
|
|
201
|
+
onlineBooking: true,
|
|
202
|
+
stack: "clerk-convex",
|
|
203
|
+
roles: ["admin", "dispatcher", "technician", "csr"],
|
|
204
|
+
customers: true,
|
|
205
|
+
jobs: true,
|
|
206
|
+
estimatesInvoices: true,
|
|
207
|
+
calendarDispatch: true,
|
|
208
|
+
payments: true,
|
|
209
|
+
comms: ["sms", "email"],
|
|
210
|
+
reviews: true,
|
|
211
|
+
reporting: true,
|
|
212
|
+
seed: true,
|
|
213
|
+
initGit: true,
|
|
214
|
+
install: true,
|
|
215
|
+
deployTarget: "vercel"
|
|
216
|
+
},
|
|
217
|
+
// Route-based + recurring is the heart of lawn care — maintenance plans + (later)
|
|
218
|
+
// route optimization belong here.
|
|
219
|
+
extraModules: ["maintenance-plans"],
|
|
220
|
+
seedFixtures: "landscaping"
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// src/prompts/industries/roofing.ts
|
|
224
|
+
var roofing = {
|
|
225
|
+
id: "roofing",
|
|
226
|
+
label: "Roofing",
|
|
227
|
+
primaryColor: "#b91c1c",
|
|
228
|
+
// red-700
|
|
229
|
+
accentColor: "#1e293b",
|
|
230
|
+
// slate-800
|
|
231
|
+
sampleTagline: "Roofs that last. Estimates that don't.",
|
|
232
|
+
serviceTypes: ["Roof Inspection", "Repair", "Full Replacement", "Gutter", "Siding", "Insurance Claim Assist"],
|
|
233
|
+
defaults: {
|
|
234
|
+
landingPage: true,
|
|
235
|
+
onlineBooking: true,
|
|
236
|
+
stack: "clerk-convex",
|
|
237
|
+
roles: ["admin", "dispatcher", "technician", "csr", "sales"],
|
|
238
|
+
customers: true,
|
|
239
|
+
jobs: true,
|
|
240
|
+
estimatesInvoices: true,
|
|
241
|
+
calendarDispatch: true,
|
|
242
|
+
payments: true,
|
|
243
|
+
comms: ["sms", "email"],
|
|
244
|
+
reviews: true,
|
|
245
|
+
reporting: true,
|
|
246
|
+
seed: true,
|
|
247
|
+
initGit: true,
|
|
248
|
+
install: true,
|
|
249
|
+
deployTarget: "vercel"
|
|
250
|
+
},
|
|
251
|
+
// Roofing = big-ticket estimates + permits + project photo documentation.
|
|
252
|
+
// (Phase 18 will add a dedicated insurance-claims module.)
|
|
253
|
+
extraModules: ["permit-tracking", "inspection-checklists"],
|
|
254
|
+
seedFixtures: "roofing"
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// src/prompts/industries/index.ts
|
|
258
|
+
var industries = {
|
|
259
|
+
general,
|
|
260
|
+
hvac,
|
|
261
|
+
plumbing,
|
|
262
|
+
electrical,
|
|
263
|
+
cleaning,
|
|
264
|
+
landscaping,
|
|
265
|
+
roofing
|
|
266
|
+
};
|
|
267
|
+
var industryOptions = [
|
|
268
|
+
{ value: "hvac", label: hvac.label, hint: "Equipment tracking + maintenance plans" },
|
|
269
|
+
{ value: "plumbing", label: plumbing.label, hint: "Emergency dispatch + permits" },
|
|
270
|
+
{ value: "electrical", label: electrical.label, hint: "Permit tracking + inspections" },
|
|
271
|
+
{ value: "cleaning", label: cleaning.label, hint: "Recurring service plans" },
|
|
272
|
+
{ value: "landscaping", label: landscaping.label, hint: "Route-based recurring jobs" },
|
|
273
|
+
{ value: "roofing", label: roofing.label, hint: "Big-ticket estimates + permits + insurance" },
|
|
274
|
+
{ value: "general", label: general.label, hint: "Core modules only \u2014 no industry preset" }
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
// src/prompts/runPrompts.ts
|
|
278
|
+
function exitIfCancelled(value) {
|
|
279
|
+
if (isCancel(value)) {
|
|
280
|
+
cancel("Cancelled. No files were written.");
|
|
281
|
+
process.exit(0);
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
var REQUIRED_KEYS = [
|
|
286
|
+
"projectName",
|
|
287
|
+
"businessName",
|
|
288
|
+
"industry",
|
|
289
|
+
"primaryColor",
|
|
290
|
+
"accentColor",
|
|
291
|
+
"landingPage",
|
|
292
|
+
"onlineBooking",
|
|
293
|
+
"stack",
|
|
294
|
+
"roles",
|
|
295
|
+
"customers",
|
|
296
|
+
"jobs",
|
|
297
|
+
"estimatesInvoices",
|
|
298
|
+
"calendarDispatch",
|
|
299
|
+
"payments",
|
|
300
|
+
"comms",
|
|
301
|
+
"reviews",
|
|
302
|
+
"reporting",
|
|
303
|
+
"seed",
|
|
304
|
+
"initGit",
|
|
305
|
+
"install",
|
|
306
|
+
"deployTarget"
|
|
307
|
+
];
|
|
308
|
+
function deriveBusinessNameFromProject(name) {
|
|
309
|
+
return name.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
310
|
+
}
|
|
311
|
+
function loadConfigFromFile(filePath) {
|
|
312
|
+
const resolved = path.resolve(process.cwd(), filePath);
|
|
313
|
+
if (!fs.existsSync(resolved)) {
|
|
314
|
+
cancel(`Config file not found: ${resolved}`);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
const raw = fs.readFileSync(resolved, "utf8");
|
|
318
|
+
let parsed;
|
|
319
|
+
try {
|
|
320
|
+
parsed = JSON.parse(raw);
|
|
321
|
+
} catch {
|
|
322
|
+
cancel(`Config file is not valid JSON: ${resolved}`);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
const presetForBackfill = parsed.industry && industries[parsed.industry] ? industries[parsed.industry] : industries.general;
|
|
326
|
+
if (!parsed.primaryColor && parsed.brandColor) {
|
|
327
|
+
parsed.primaryColor = parsed.brandColor;
|
|
328
|
+
}
|
|
329
|
+
if (!parsed.primaryColor) parsed.primaryColor = presetForBackfill.primaryColor;
|
|
330
|
+
if (!parsed.accentColor) parsed.accentColor = presetForBackfill.accentColor;
|
|
331
|
+
if (!parsed.businessName && parsed.projectName) {
|
|
332
|
+
parsed.businessName = deriveBusinessNameFromProject(parsed.projectName);
|
|
333
|
+
}
|
|
334
|
+
if (!parsed.deployTarget) parsed.deployTarget = "vercel";
|
|
335
|
+
const missing = REQUIRED_KEYS.filter((k) => parsed[k] === void 0);
|
|
336
|
+
if (missing.length > 0) {
|
|
337
|
+
cancel(`Config missing required keys: ${missing.join(", ")}`);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
...parsed,
|
|
342
|
+
industryExtras: parsed.industryExtras ?? []
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
async function runPrompts(argv) {
|
|
346
|
+
if (argv.configPath) {
|
|
347
|
+
const cfg = loadConfigFromFile(argv.configPath);
|
|
348
|
+
if (argv.projectName) cfg.projectName = argv.projectName;
|
|
349
|
+
return cfg;
|
|
350
|
+
}
|
|
351
|
+
note(
|
|
352
|
+
pc.dim("Step 1 of 4 \u2014 tell us about the business"),
|
|
353
|
+
pc.cyan("Identity")
|
|
354
|
+
);
|
|
355
|
+
const projectName = exitIfCancelled(
|
|
356
|
+
argv.projectName ?? await text({
|
|
357
|
+
message: "Project name (folder + npm package \u2014 lowercase, dashed)",
|
|
358
|
+
placeholder: "acme-hvac",
|
|
359
|
+
defaultValue: "my-crm",
|
|
360
|
+
validate(value) {
|
|
361
|
+
const v = value.trim();
|
|
362
|
+
if (!v) return "Project name is required.";
|
|
363
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/i.test(v)) {
|
|
364
|
+
return "Use letters, numbers, dashes, underscores only.";
|
|
365
|
+
}
|
|
366
|
+
const target = path.resolve(process.cwd(), v);
|
|
367
|
+
if (fs.existsSync(target) && fs.readdirSync(target).length > 0) {
|
|
368
|
+
return `Directory "${v}" already exists and is not empty.`;
|
|
369
|
+
}
|
|
370
|
+
return void 0;
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
const businessName = exitIfCancelled(
|
|
375
|
+
await text({
|
|
376
|
+
message: "Business display name (shown in UI, emails, invoices)",
|
|
377
|
+
placeholder: deriveBusinessNameFromProject(projectName),
|
|
378
|
+
defaultValue: deriveBusinessNameFromProject(projectName),
|
|
379
|
+
validate(value) {
|
|
380
|
+
if (!value || !value.trim()) return "Business name is required.";
|
|
381
|
+
return void 0;
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
);
|
|
385
|
+
const businessLegalName = exitIfCancelled(
|
|
386
|
+
await text({
|
|
387
|
+
message: pc.dim("Legal business name for invoices/contracts (optional \u2014 Enter to skip)"),
|
|
388
|
+
placeholder: `${businessName} LLC`,
|
|
389
|
+
defaultValue: ""
|
|
390
|
+
})
|
|
391
|
+
);
|
|
392
|
+
const industry = exitIfCancelled(
|
|
393
|
+
await select({
|
|
394
|
+
message: "Industry vertical",
|
|
395
|
+
options: industryOptions,
|
|
396
|
+
initialValue: "general"
|
|
397
|
+
})
|
|
398
|
+
);
|
|
399
|
+
const preset = industries[industry];
|
|
400
|
+
const tagline = exitIfCancelled(
|
|
401
|
+
await text({
|
|
402
|
+
message: pc.dim("Tagline / one-liner for the landing page (optional)"),
|
|
403
|
+
placeholder: preset.sampleTagline,
|
|
404
|
+
defaultValue: ""
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
note(
|
|
408
|
+
pc.dim("Step 2 of 4 \u2014 make it look like their brand"),
|
|
409
|
+
pc.cyan("Branding")
|
|
410
|
+
);
|
|
411
|
+
const hexValidator = (value) => {
|
|
412
|
+
if (!value) return void 0;
|
|
413
|
+
if (!/^#[0-9a-f]{6}$/i.test(value.trim())) {
|
|
414
|
+
return "Use a 6-digit hex color like #0ea5e9";
|
|
415
|
+
}
|
|
416
|
+
return void 0;
|
|
417
|
+
};
|
|
418
|
+
const primaryColor = exitIfCancelled(
|
|
419
|
+
await text({
|
|
420
|
+
message: `Primary brand color (hex) \u2014 used for buttons, links, headers`,
|
|
421
|
+
placeholder: preset.primaryColor,
|
|
422
|
+
defaultValue: preset.primaryColor,
|
|
423
|
+
validate: hexValidator
|
|
424
|
+
})
|
|
425
|
+
);
|
|
426
|
+
const accentColor = exitIfCancelled(
|
|
427
|
+
await text({
|
|
428
|
+
message: `Accent color (hex) \u2014 used for highlights, badges, secondary CTAs`,
|
|
429
|
+
placeholder: preset.accentColor,
|
|
430
|
+
defaultValue: preset.accentColor,
|
|
431
|
+
validate: hexValidator
|
|
432
|
+
})
|
|
433
|
+
);
|
|
434
|
+
const logoPath = exitIfCancelled(
|
|
435
|
+
await text({
|
|
436
|
+
message: pc.dim("Logo file path (PNG/SVG/JPG \u2014 optional, copied to public/)"),
|
|
437
|
+
placeholder: "~/Desktop/acme-logo.png",
|
|
438
|
+
defaultValue: "",
|
|
439
|
+
validate(value) {
|
|
440
|
+
if (!value || !value.trim()) return void 0;
|
|
441
|
+
const expanded = value.replace(/^~/, process.env.HOME ?? "");
|
|
442
|
+
if (!fs.existsSync(expanded)) {
|
|
443
|
+
return `File not found: ${expanded}`;
|
|
444
|
+
}
|
|
445
|
+
if (!/\.(png|jpg|jpeg|svg|webp)$/i.test(expanded)) {
|
|
446
|
+
return "Logo must be PNG, JPG, SVG, or WebP";
|
|
447
|
+
}
|
|
448
|
+
return void 0;
|
|
449
|
+
}
|
|
450
|
+
})
|
|
451
|
+
);
|
|
452
|
+
note(
|
|
453
|
+
pc.dim("Step 3 of 4 \u2014 contact details (all optional, used in footer + transactional emails)"),
|
|
454
|
+
pc.cyan("Contact")
|
|
455
|
+
);
|
|
456
|
+
const contactAnswers = await group(
|
|
457
|
+
{
|
|
458
|
+
businessPhone: () => text({
|
|
459
|
+
message: pc.dim("Business phone"),
|
|
460
|
+
placeholder: "(555) 010-1234",
|
|
461
|
+
defaultValue: ""
|
|
462
|
+
}),
|
|
463
|
+
businessEmail: () => text({
|
|
464
|
+
message: pc.dim("Business email"),
|
|
465
|
+
placeholder: "hello@acmehvac.com",
|
|
466
|
+
defaultValue: "",
|
|
467
|
+
validate(value) {
|
|
468
|
+
if (!value || !value.trim()) return void 0;
|
|
469
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())) {
|
|
470
|
+
return "Use a valid email address";
|
|
471
|
+
}
|
|
472
|
+
return void 0;
|
|
473
|
+
}
|
|
474
|
+
}),
|
|
475
|
+
businessAddress: () => text({
|
|
476
|
+
message: pc.dim("Mailing address (single line)"),
|
|
477
|
+
placeholder: "120 Main St, Austin, TX 78701",
|
|
478
|
+
defaultValue: ""
|
|
479
|
+
}),
|
|
480
|
+
serviceAreaZips: () => text({
|
|
481
|
+
message: pc.dim("Service-area ZIP codes (comma-separated)"),
|
|
482
|
+
placeholder: "78701, 78704, 78745",
|
|
483
|
+
defaultValue: ""
|
|
484
|
+
})
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
onCancel: () => {
|
|
488
|
+
cancel("Cancelled. No files were written.");
|
|
489
|
+
process.exit(0);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
note(
|
|
494
|
+
pc.dim("Step 4 of 4 \u2014 features + stack + first admin account"),
|
|
495
|
+
pc.cyan("Features")
|
|
496
|
+
);
|
|
497
|
+
const answers = await group(
|
|
498
|
+
{
|
|
499
|
+
stack: () => select({
|
|
500
|
+
message: "Auth + database stack",
|
|
501
|
+
options: [
|
|
502
|
+
{
|
|
503
|
+
value: "better-auth-drizzle",
|
|
504
|
+
label: "Better-Auth + Postgres + Drizzle",
|
|
505
|
+
hint: "full-featured \u2014 recommended for client delivery"
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
value: "clerk-convex",
|
|
509
|
+
label: "Clerk + Convex",
|
|
510
|
+
hint: "core modules only \u2014 customers + jobs are fully working; estimates/invoices/portal are Drizzle-only for now (experimental)"
|
|
511
|
+
}
|
|
512
|
+
],
|
|
513
|
+
initialValue: preset.defaults.stack ?? "clerk-convex"
|
|
514
|
+
}),
|
|
515
|
+
roles: () => multiselect({
|
|
516
|
+
message: "User roles to include",
|
|
517
|
+
options: [
|
|
518
|
+
{ value: "admin", label: "Owner / Admin" },
|
|
519
|
+
{ value: "dispatcher", label: "Dispatcher" },
|
|
520
|
+
{ value: "technician", label: "Field Technician" },
|
|
521
|
+
{ value: "csr", label: "CSR (Customer Service Rep)" },
|
|
522
|
+
{ value: "sales", label: "Sales / Estimator" },
|
|
523
|
+
{ value: "accountant", label: "Accountant" }
|
|
524
|
+
],
|
|
525
|
+
initialValues: preset.defaults.roles ?? ["admin", "dispatcher", "technician", "csr"],
|
|
526
|
+
required: true
|
|
527
|
+
}),
|
|
528
|
+
landingPage: () => confirm({
|
|
529
|
+
message: "Public landing page (marketing site)?",
|
|
530
|
+
initialValue: preset.defaults.landingPage ?? true
|
|
531
|
+
}),
|
|
532
|
+
onlineBooking: ({ results }) => results.landingPage ? confirm({
|
|
533
|
+
message: "Online booking widget on the landing page?",
|
|
534
|
+
initialValue: preset.defaults.onlineBooking ?? true
|
|
535
|
+
}) : Promise.resolve(false),
|
|
536
|
+
customers: () => confirm({
|
|
537
|
+
message: "Customers / Contacts module?",
|
|
538
|
+
initialValue: preset.defaults.customers ?? true
|
|
539
|
+
}),
|
|
540
|
+
jobs: () => confirm({
|
|
541
|
+
message: "Jobs / Work Orders module?",
|
|
542
|
+
initialValue: preset.defaults.jobs ?? true
|
|
543
|
+
}),
|
|
544
|
+
estimatesInvoices: () => confirm({
|
|
545
|
+
message: "Estimates & Invoices module?",
|
|
546
|
+
initialValue: preset.defaults.estimatesInvoices ?? true
|
|
547
|
+
}),
|
|
548
|
+
calendarDispatch: () => confirm({
|
|
549
|
+
message: "Calendar + Dispatch board?",
|
|
550
|
+
initialValue: preset.defaults.calendarDispatch ?? true
|
|
551
|
+
}),
|
|
552
|
+
payments: () => confirm({
|
|
553
|
+
message: "Payments (Stripe)?",
|
|
554
|
+
initialValue: preset.defaults.payments ?? true
|
|
555
|
+
}),
|
|
556
|
+
comms: () => multiselect({
|
|
557
|
+
message: "Communication channels",
|
|
558
|
+
options: [
|
|
559
|
+
{ value: "sms", label: "SMS (Twilio)" },
|
|
560
|
+
{ value: "email", label: "Email (Resend)" }
|
|
561
|
+
],
|
|
562
|
+
initialValues: preset.defaults.comms ?? ["sms", "email"],
|
|
563
|
+
required: false
|
|
564
|
+
}),
|
|
565
|
+
reviews: () => confirm({
|
|
566
|
+
message: "Reviews & marketing automation?",
|
|
567
|
+
initialValue: preset.defaults.reviews ?? true
|
|
568
|
+
}),
|
|
569
|
+
reporting: () => confirm({
|
|
570
|
+
message: "Reporting dashboard?",
|
|
571
|
+
initialValue: preset.defaults.reporting ?? true
|
|
572
|
+
}),
|
|
573
|
+
mobileApp: ({ results }) => results.stack === "better-auth-drizzle" && results.jobs && results.customers ? confirm({
|
|
574
|
+
message: "Field-tech iPhone app? (Expo \u2014 adds a mobile API + branded app)",
|
|
575
|
+
initialValue: preset.defaults.mobileApp ?? true
|
|
576
|
+
}) : Promise.resolve(false),
|
|
577
|
+
ownerName: () => text({
|
|
578
|
+
message: pc.dim("First admin user \u2014 full name (optional, pre-seeded in dev)"),
|
|
579
|
+
placeholder: "Joe Smith",
|
|
580
|
+
defaultValue: ""
|
|
581
|
+
}),
|
|
582
|
+
ownerEmail: () => text({
|
|
583
|
+
message: pc.dim("First admin user \u2014 email (optional)"),
|
|
584
|
+
placeholder: "joe@acmehvac.com",
|
|
585
|
+
defaultValue: "",
|
|
586
|
+
validate(value) {
|
|
587
|
+
if (!value || !value.trim()) return void 0;
|
|
588
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())) {
|
|
589
|
+
return "Use a valid email address";
|
|
590
|
+
}
|
|
591
|
+
return void 0;
|
|
592
|
+
}
|
|
593
|
+
}),
|
|
594
|
+
deployTarget: () => select({
|
|
595
|
+
message: "Deploy target (drives README + setup-script)",
|
|
596
|
+
options: [
|
|
597
|
+
{ value: "vercel", label: "Vercel", hint: "one-click deploy button in README" },
|
|
598
|
+
{ value: "netlify", label: "Netlify", hint: "netlify.toml + deploy button" },
|
|
599
|
+
{ value: "manual", label: "Self-host / manual", hint: "plain dockerfile + docs" }
|
|
600
|
+
],
|
|
601
|
+
initialValue: preset.defaults.deployTarget ?? "vercel"
|
|
602
|
+
}),
|
|
603
|
+
seed: () => confirm({
|
|
604
|
+
message: "Generate realistic demo seed data?",
|
|
605
|
+
initialValue: preset.defaults.seed ?? true
|
|
606
|
+
}),
|
|
607
|
+
initGit: () => confirm({
|
|
608
|
+
message: "Initialize a git repository?",
|
|
609
|
+
initialValue: preset.defaults.initGit ?? true
|
|
610
|
+
}),
|
|
611
|
+
install: () => confirm({
|
|
612
|
+
message: "Install dependencies now?",
|
|
613
|
+
initialValue: preset.defaults.install ?? true
|
|
614
|
+
})
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
onCancel: () => {
|
|
618
|
+
cancel("Cancelled. No files were written.");
|
|
619
|
+
process.exit(0);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
);
|
|
623
|
+
const config = {
|
|
624
|
+
projectName,
|
|
625
|
+
businessName: businessName.trim(),
|
|
626
|
+
businessLegalName: businessLegalName.trim() || void 0,
|
|
627
|
+
tagline: tagline.trim() || void 0,
|
|
628
|
+
industry,
|
|
629
|
+
primaryColor,
|
|
630
|
+
accentColor,
|
|
631
|
+
logoPath: logoPath.trim() || void 0,
|
|
632
|
+
businessPhone: contactAnswers.businessPhone.trim() || void 0,
|
|
633
|
+
businessEmail: contactAnswers.businessEmail.trim() || void 0,
|
|
634
|
+
businessAddress: contactAnswers.businessAddress.trim() || void 0,
|
|
635
|
+
serviceAreaZips: contactAnswers.serviceAreaZips.trim() || void 0,
|
|
636
|
+
stack: answers.stack,
|
|
637
|
+
roles: answers.roles,
|
|
638
|
+
landingPage: answers.landingPage,
|
|
639
|
+
onlineBooking: Boolean(answers.onlineBooking),
|
|
640
|
+
customers: answers.customers,
|
|
641
|
+
jobs: answers.jobs,
|
|
642
|
+
estimatesInvoices: answers.estimatesInvoices,
|
|
643
|
+
calendarDispatch: answers.calendarDispatch,
|
|
644
|
+
payments: answers.payments,
|
|
645
|
+
comms: answers.comms,
|
|
646
|
+
reviews: answers.reviews,
|
|
647
|
+
reporting: answers.reporting,
|
|
648
|
+
mobileApp: Boolean(answers.mobileApp),
|
|
649
|
+
industryExtras: preset.extraModules,
|
|
650
|
+
ownerName: answers.ownerName.trim() || void 0,
|
|
651
|
+
ownerEmail: answers.ownerEmail.trim() || void 0,
|
|
652
|
+
seed: answers.seed,
|
|
653
|
+
initGit: answers.initGit,
|
|
654
|
+
install: answers.install,
|
|
655
|
+
deployTarget: answers.deployTarget
|
|
656
|
+
};
|
|
657
|
+
return config;
|
|
658
|
+
}
|
|
659
|
+
function summarizeConfig(cfg) {
|
|
660
|
+
const lines = [
|
|
661
|
+
`${pc.bold("Project:")} ${cfg.projectName}`,
|
|
662
|
+
`${pc.bold("Business:")} ${cfg.businessName}${cfg.businessLegalName ? pc.dim(` (${cfg.businessLegalName})`) : ""}`,
|
|
663
|
+
cfg.tagline ? `${pc.bold("Tagline:")} ${pc.italic(cfg.tagline)}` : null,
|
|
664
|
+
`${pc.bold("Industry:")} ${industries[cfg.industry].label}`,
|
|
665
|
+
`${pc.bold("Stack:")} ${cfg.stack}`,
|
|
666
|
+
`${pc.bold("Modules:")} ${[
|
|
667
|
+
cfg.landingPage && "landing-page",
|
|
668
|
+
"auth",
|
|
669
|
+
"admin",
|
|
670
|
+
cfg.customers && "customers",
|
|
671
|
+
cfg.jobs && "jobs",
|
|
672
|
+
cfg.estimatesInvoices && "estimates-invoices",
|
|
673
|
+
cfg.calendarDispatch && "calendar-dispatch",
|
|
674
|
+
cfg.payments && "payments-stripe",
|
|
675
|
+
cfg.comms.includes("sms") && "comms-sms",
|
|
676
|
+
cfg.comms.includes("email") && "comms-email",
|
|
677
|
+
cfg.reviews && "reviews",
|
|
678
|
+
cfg.reporting && "reporting",
|
|
679
|
+
cfg.mobileApp && "mobile (iPhone app)",
|
|
680
|
+
...cfg.industryExtras
|
|
681
|
+
].filter(Boolean).join(", ")}`,
|
|
682
|
+
`${pc.bold("Roles:")} ${cfg.roles.join(", ")}`,
|
|
683
|
+
`${pc.bold("Brand:")} primary ${pc.cyan(cfg.primaryColor)} \xB7 accent ${pc.magenta(cfg.accentColor)}${cfg.logoPath ? pc.dim(` \xB7 logo: ${cfg.logoPath}`) : ""}`,
|
|
684
|
+
cfg.businessPhone || cfg.businessEmail || cfg.businessAddress ? `${pc.bold("Contact:")} ${[cfg.businessPhone, cfg.businessEmail, cfg.businessAddress].filter(Boolean).join(" \xB7 ")}` : null,
|
|
685
|
+
cfg.serviceAreaZips ? `${pc.bold("Service area:")} ${cfg.serviceAreaZips}` : null,
|
|
686
|
+
cfg.ownerName || cfg.ownerEmail ? `${pc.bold("First admin:")} ${[cfg.ownerName, cfg.ownerEmail].filter(Boolean).join(" \xB7 ")}` : null,
|
|
687
|
+
`${pc.bold("Deploy:")} ${cfg.deployTarget}`,
|
|
688
|
+
`${pc.bold("Seed:")} ${cfg.seed ? "yes" : "no"} \xB7 ${pc.bold("Git:")} ${cfg.initGit ? "yes" : "no"} \xB7 ${pc.bold("Install:")} ${cfg.install ? "yes" : "no"}`
|
|
689
|
+
];
|
|
690
|
+
return lines.filter((l) => l !== null).join("\n");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/helpers/copyTemplate.ts
|
|
694
|
+
import fs3 from "fs/promises";
|
|
695
|
+
import path3 from "path";
|
|
696
|
+
|
|
697
|
+
// src/helpers/paths.ts
|
|
698
|
+
import { fileURLToPath } from "url";
|
|
699
|
+
import path2 from "path";
|
|
700
|
+
import fs2 from "fs";
|
|
701
|
+
var here = path2.dirname(fileURLToPath(import.meta.url));
|
|
702
|
+
function findPackageRoot(start) {
|
|
703
|
+
let dir = start;
|
|
704
|
+
for (let i = 0; i < 8; i++) {
|
|
705
|
+
if (fs2.existsSync(path2.join(dir, "template", "base"))) return dir;
|
|
706
|
+
const parent = path2.dirname(dir);
|
|
707
|
+
if (parent === dir) break;
|
|
708
|
+
dir = parent;
|
|
709
|
+
}
|
|
710
|
+
dir = start;
|
|
711
|
+
for (let i = 0; i < 8; i++) {
|
|
712
|
+
if (fs2.existsSync(path2.join(dir, "package.json"))) return dir;
|
|
713
|
+
const parent = path2.dirname(dir);
|
|
714
|
+
if (parent === dir) break;
|
|
715
|
+
dir = parent;
|
|
716
|
+
}
|
|
717
|
+
return path2.resolve(here, "..");
|
|
718
|
+
}
|
|
719
|
+
var packageRoot = findPackageRoot(here);
|
|
720
|
+
var templateBaseDir = path2.join(packageRoot, "template", "base");
|
|
721
|
+
var templateExtrasDir = path2.join(packageRoot, "template", "extras");
|
|
722
|
+
|
|
723
|
+
// src/helpers/copyTemplate.ts
|
|
724
|
+
function rewriteName(name) {
|
|
725
|
+
if (name.startsWith("_dot_")) return "." + name.slice("_dot_".length);
|
|
726
|
+
return name;
|
|
727
|
+
}
|
|
728
|
+
async function copyDir(src, dest) {
|
|
729
|
+
await fs3.mkdir(dest, { recursive: true });
|
|
730
|
+
const entries = await fs3.readdir(src, { withFileTypes: true });
|
|
731
|
+
for (const entry of entries) {
|
|
732
|
+
const srcPath = path3.join(src, entry.name);
|
|
733
|
+
const destPath = path3.join(dest, rewriteName(entry.name));
|
|
734
|
+
if (entry.isDirectory()) {
|
|
735
|
+
await copyDir(srcPath, destPath);
|
|
736
|
+
} else if (entry.isFile()) {
|
|
737
|
+
await fs3.cp(srcPath, destPath);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async function copyBase(projectDir) {
|
|
742
|
+
await copyDir(templateBaseDir, projectDir);
|
|
743
|
+
}
|
|
744
|
+
async function copyExtra(extraSubpath, projectDir) {
|
|
745
|
+
const src = path3.join(templateExtrasDir, extraSubpath);
|
|
746
|
+
await copyDir(src, projectDir);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/helpers/mergeEnv.ts
|
|
750
|
+
import fs4 from "fs/promises";
|
|
751
|
+
import path4 from "path";
|
|
752
|
+
async function mergeEnv(projectDir, block) {
|
|
753
|
+
if (Object.keys(block.vars).length === 0) return;
|
|
754
|
+
const examplePath = path4.join(projectDir, ".env.example");
|
|
755
|
+
const localPath = path4.join(projectDir, ".env.local");
|
|
756
|
+
const defaults = block.localDefaults ?? {};
|
|
757
|
+
const exampleLines = [
|
|
758
|
+
"",
|
|
759
|
+
`# --- ${block.heading} ---`,
|
|
760
|
+
...Object.entries(block.vars).map(([k, v]) => `${k}=${v}`)
|
|
761
|
+
].join("\n");
|
|
762
|
+
const localLines = [
|
|
763
|
+
"",
|
|
764
|
+
`# --- ${block.heading} ---`,
|
|
765
|
+
...Object.keys(block.vars).map((k) => `${k}=${defaults[k] ?? ""}`)
|
|
766
|
+
].join("\n");
|
|
767
|
+
await fs4.appendFile(examplePath, exampleLines, "utf8");
|
|
768
|
+
await fs4.appendFile(localPath, localLines, "utf8");
|
|
769
|
+
}
|
|
770
|
+
async function ensureEnvFiles(projectDir) {
|
|
771
|
+
const examplePath = path4.join(projectDir, ".env.example");
|
|
772
|
+
const localPath = path4.join(projectDir, ".env.local");
|
|
773
|
+
try {
|
|
774
|
+
await fs4.access(examplePath);
|
|
775
|
+
} catch {
|
|
776
|
+
await fs4.writeFile(examplePath, "# Environment variables\n", "utf8");
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
await fs4.access(localPath);
|
|
780
|
+
} catch {
|
|
781
|
+
await fs4.writeFile(
|
|
782
|
+
localPath,
|
|
783
|
+
"# Local environment overrides (gitignored)\n",
|
|
784
|
+
"utf8"
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/helpers/mergePackageJson.ts
|
|
790
|
+
import fs5 from "fs/promises";
|
|
791
|
+
import path5 from "path";
|
|
792
|
+
async function readPackageJson(projectDir) {
|
|
793
|
+
const raw = await fs5.readFile(path5.join(projectDir, "package.json"), "utf8");
|
|
794
|
+
return JSON.parse(raw);
|
|
795
|
+
}
|
|
796
|
+
async function writePackageJson(projectDir, pkg) {
|
|
797
|
+
await fs5.writeFile(
|
|
798
|
+
path5.join(projectDir, "package.json"),
|
|
799
|
+
JSON.stringify(pkg, null, 2) + "\n",
|
|
800
|
+
"utf8"
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
function mergePackageJson(pkg, patch) {
|
|
804
|
+
if (patch.scripts) {
|
|
805
|
+
pkg.scripts = { ...pkg.scripts, ...patch.scripts };
|
|
806
|
+
}
|
|
807
|
+
if (patch.dependencies) {
|
|
808
|
+
pkg.dependencies = { ...pkg.dependencies, ...patch.dependencies };
|
|
809
|
+
}
|
|
810
|
+
if (patch.devDependencies) {
|
|
811
|
+
pkg.devDependencies = {
|
|
812
|
+
...pkg.devDependencies,
|
|
813
|
+
...patch.devDependencies
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
817
|
+
if (key === "scripts" || key === "dependencies" || key === "devDependencies") {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
pkg[key] = value;
|
|
821
|
+
}
|
|
822
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
823
|
+
const map = pkg[field];
|
|
824
|
+
if (map) {
|
|
825
|
+
pkg[field] = Object.fromEntries(
|
|
826
|
+
Object.entries(map).sort(([a], [b]) => a.localeCompare(b))
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return pkg;
|
|
831
|
+
}
|
|
832
|
+
function setProjectName(pkg, name) {
|
|
833
|
+
pkg.name = name;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// src/helpers/installDeps.ts
|
|
837
|
+
import { execa } from "execa";
|
|
838
|
+
import { spinner } from "@clack/prompts";
|
|
839
|
+
|
|
840
|
+
// src/helpers/detectPM.ts
|
|
841
|
+
import { detect } from "package-manager-detector/detect";
|
|
842
|
+
async function detectPackageManager(cwd = process.cwd()) {
|
|
843
|
+
const detected = await detect({ cwd });
|
|
844
|
+
if (detected?.name === "pnpm") return "pnpm";
|
|
845
|
+
if (detected?.name === "yarn") return "yarn";
|
|
846
|
+
if (detected?.name === "bun") return "bun";
|
|
847
|
+
const ua = process.env.npm_config_user_agent ?? "";
|
|
848
|
+
if (ua.startsWith("pnpm")) return "pnpm";
|
|
849
|
+
if (ua.startsWith("yarn")) return "yarn";
|
|
850
|
+
if (ua.startsWith("bun")) return "bun";
|
|
851
|
+
return "npm";
|
|
852
|
+
}
|
|
853
|
+
function installCommand(pm) {
|
|
854
|
+
switch (pm) {
|
|
855
|
+
case "pnpm":
|
|
856
|
+
return { command: "pnpm", args: ["install"] };
|
|
857
|
+
case "yarn":
|
|
858
|
+
return { command: "yarn", args: [] };
|
|
859
|
+
case "bun":
|
|
860
|
+
return { command: "bun", args: ["install"] };
|
|
861
|
+
case "npm":
|
|
862
|
+
default:
|
|
863
|
+
return { command: "npm", args: ["install"] };
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function runScript(pm, script) {
|
|
867
|
+
switch (pm) {
|
|
868
|
+
case "npm":
|
|
869
|
+
return `npm run ${script}`;
|
|
870
|
+
default:
|
|
871
|
+
return `${pm} ${script}`;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/helpers/installDeps.ts
|
|
876
|
+
async function installDeps(projectDir) {
|
|
877
|
+
const pm = await detectPackageManager();
|
|
878
|
+
const { command, args } = installCommand(pm);
|
|
879
|
+
const s = spinner();
|
|
880
|
+
s.start(`Installing dependencies with ${pm}`);
|
|
881
|
+
try {
|
|
882
|
+
await execa(command, args, { cwd: projectDir, stdio: "pipe" });
|
|
883
|
+
s.stop(`Installed dependencies with ${pm}`);
|
|
884
|
+
} catch (err) {
|
|
885
|
+
s.stop(`Dependency install failed \u2014 run \`${command} ${args.join(" ")}\` manually.`);
|
|
886
|
+
throw err;
|
|
887
|
+
}
|
|
888
|
+
return pm;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/helpers/initGit.ts
|
|
892
|
+
import path6 from "path";
|
|
893
|
+
import fs6 from "fs/promises";
|
|
894
|
+
import { execa as execa2 } from "execa";
|
|
895
|
+
async function isInsideGitRepo(dir) {
|
|
896
|
+
try {
|
|
897
|
+
const { stdout } = await execa2("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
898
|
+
cwd: dir,
|
|
899
|
+
stdio: "pipe"
|
|
900
|
+
});
|
|
901
|
+
return stdout.trim() === "true";
|
|
902
|
+
} catch {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
async function initGit(projectDir) {
|
|
907
|
+
const dotGit = path6.join(projectDir, ".git");
|
|
908
|
+
try {
|
|
909
|
+
await fs6.access(dotGit);
|
|
910
|
+
return false;
|
|
911
|
+
} catch {
|
|
912
|
+
}
|
|
913
|
+
if (await isInsideGitRepo(path6.dirname(projectDir))) {
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
await execa2("git", ["init", "-b", "main"], { cwd: projectDir, stdio: "pipe" });
|
|
917
|
+
await execa2("git", ["add", "-A"], { cwd: projectDir, stdio: "pipe" });
|
|
918
|
+
await execa2(
|
|
919
|
+
"git",
|
|
920
|
+
["commit", "-m", "Initial commit from create-crm-starter", "--no-verify"],
|
|
921
|
+
{ cwd: projectDir, stdio: "pipe", env: { ...process.env, GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "create-crm-starter", GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? "noreply@create-crm-starter" } }
|
|
922
|
+
).catch(() => {
|
|
923
|
+
});
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// src/helpers/nextSteps.ts
|
|
928
|
+
import pc2 from "picocolors";
|
|
929
|
+
import { note as note2 } from "@clack/prompts";
|
|
930
|
+
function printNextSteps(opts) {
|
|
931
|
+
const { cfg, pm, installed } = opts;
|
|
932
|
+
const lines = [];
|
|
933
|
+
lines.push(pc2.cyan(`cd ${cfg.projectName}`));
|
|
934
|
+
if (!installed) {
|
|
935
|
+
lines.push(pc2.cyan(`${pm} install`));
|
|
936
|
+
}
|
|
937
|
+
if (cfg.stack === "clerk-convex") {
|
|
938
|
+
lines.push("");
|
|
939
|
+
lines.push(pc2.dim("# Configure Clerk (sign up at https://clerk.com)"));
|
|
940
|
+
lines.push(pc2.dim("# then fill NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY + CLERK_SECRET_KEY in .env.local"));
|
|
941
|
+
lines.push("");
|
|
942
|
+
lines.push(pc2.dim("# Configure Convex"));
|
|
943
|
+
lines.push(pc2.cyan(`${runScript(pm, "convex:dev")} # follow prompts to create deployment`));
|
|
944
|
+
} else {
|
|
945
|
+
lines.push("");
|
|
946
|
+
lines.push(pc2.dim("# Set DATABASE_URL in .env.local (Postgres). Local Docker: `docker compose up -d`"));
|
|
947
|
+
lines.push(pc2.cyan(`${runScript(pm, "db:push")} # apply schema`));
|
|
948
|
+
}
|
|
949
|
+
if (cfg.seed) {
|
|
950
|
+
lines.push(pc2.cyan(`${runScript(pm, "db:seed")} # populate demo data`));
|
|
951
|
+
}
|
|
952
|
+
lines.push(pc2.cyan(`${runScript(pm, "dev")} # start dev server`));
|
|
953
|
+
note2(lines.join("\n"), "Next steps");
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// src/installers/auth-clerk.ts
|
|
957
|
+
var installAuthClerk = async ({ projectDir, pkg }) => {
|
|
958
|
+
await copyExtra("auth-clerk", projectDir);
|
|
959
|
+
mergePackageJson(pkg, {
|
|
960
|
+
dependencies: {
|
|
961
|
+
"@clerk/nextjs": "^6.36.6"
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
await mergeEnv(projectDir, {
|
|
965
|
+
heading: "Clerk (https://clerk.com)",
|
|
966
|
+
vars: {
|
|
967
|
+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_test_...",
|
|
968
|
+
CLERK_SECRET_KEY: "sk_test_...",
|
|
969
|
+
NEXT_PUBLIC_CLERK_SIGN_IN_URL: "/sign-in",
|
|
970
|
+
NEXT_PUBLIC_CLERK_SIGN_UP_URL: "/sign-up"
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
// src/installers/db-convex.ts
|
|
976
|
+
var installDbConvex = async ({ projectDir, pkg }) => {
|
|
977
|
+
await copyExtra("db-convex", projectDir);
|
|
978
|
+
mergePackageJson(pkg, {
|
|
979
|
+
dependencies: {
|
|
980
|
+
convex: "^1.31.2"
|
|
981
|
+
},
|
|
982
|
+
scripts: {
|
|
983
|
+
"convex:dev": "convex dev",
|
|
984
|
+
"convex:deploy": "convex deploy"
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
await mergeEnv(projectDir, {
|
|
988
|
+
heading: "Convex (https://convex.dev)",
|
|
989
|
+
vars: {
|
|
990
|
+
NEXT_PUBLIC_CONVEX_URL: "https://<your-deployment>.convex.cloud"
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
// src/installers/auth-better-auth.ts
|
|
996
|
+
import crypto from "crypto";
|
|
997
|
+
var installAuthBetterAuth = async ({ projectDir, pkg }) => {
|
|
998
|
+
await copyExtra("auth-better-auth", projectDir);
|
|
999
|
+
mergePackageJson(pkg, {
|
|
1000
|
+
dependencies: {
|
|
1001
|
+
// Pinned exact: better-auth 1.6.12 ships a @better-auth/kysely-adapter
|
|
1002
|
+
// that imports DEFAULT_MIGRATION_TABLE from a kysely version that no
|
|
1003
|
+
// longer exports it, breaking `next build`. 1.6.11 is the last known-good.
|
|
1004
|
+
// Re-test before bumping (and prefer reproducible client builds anyway).
|
|
1005
|
+
"better-auth": "1.6.11"
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
const generatedSecret = crypto.randomBytes(32).toString("hex");
|
|
1009
|
+
await mergeEnv(projectDir, {
|
|
1010
|
+
heading: "Better-Auth",
|
|
1011
|
+
// .env.example gets placeholders (never the real secret — it's committed).
|
|
1012
|
+
vars: {
|
|
1013
|
+
BETTER_AUTH_SECRET: "<generated at scaffold \u2014 see .env.local; regenerate for prod>",
|
|
1014
|
+
BETTER_AUTH_URL: "http://localhost:3000",
|
|
1015
|
+
NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000"
|
|
1016
|
+
},
|
|
1017
|
+
// .env.local is pre-filled so `pnpm dev` works first-try (secret +
|
|
1018
|
+
// localhost URLs). For production, set a fresh secret + real URL in Vercel.
|
|
1019
|
+
localDefaults: {
|
|
1020
|
+
BETTER_AUTH_SECRET: generatedSecret,
|
|
1021
|
+
BETTER_AUTH_URL: "http://localhost:3000",
|
|
1022
|
+
NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000"
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
// src/installers/db-drizzle-pg.ts
|
|
1028
|
+
var installDbDrizzlePg = async ({ projectDir, pkg }) => {
|
|
1029
|
+
await copyExtra("db-drizzle-pg", projectDir);
|
|
1030
|
+
mergePackageJson(pkg, {
|
|
1031
|
+
dependencies: {
|
|
1032
|
+
"@tanstack/react-query": "^5.62.0",
|
|
1033
|
+
"drizzle-orm": "^0.36.0",
|
|
1034
|
+
postgres: "^3.4.5"
|
|
1035
|
+
},
|
|
1036
|
+
devDependencies: {
|
|
1037
|
+
dotenv: "^16.4.7",
|
|
1038
|
+
"drizzle-kit": "^0.30.0",
|
|
1039
|
+
tsx: "^4.19.0"
|
|
1040
|
+
},
|
|
1041
|
+
scripts: {
|
|
1042
|
+
"db:generate": "drizzle-kit generate",
|
|
1043
|
+
"db:push": "drizzle-kit push",
|
|
1044
|
+
"db:studio": "drizzle-kit studio"
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
await mergeEnv(projectDir, {
|
|
1048
|
+
heading: "Postgres + Drizzle",
|
|
1049
|
+
vars: {
|
|
1050
|
+
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/crm"
|
|
1051
|
+
},
|
|
1052
|
+
// Pre-fill the local docker-compose connection string so
|
|
1053
|
+
// `docker compose up -d && pnpm db:push` works first-try in dev.
|
|
1054
|
+
// Swap for your Neon URL (or set it in Vercel) for production.
|
|
1055
|
+
localDefaults: {
|
|
1056
|
+
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/crm"
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// src/installers/landing-page.ts
|
|
1062
|
+
var installLandingPage = async ({ projectDir, cfg }) => {
|
|
1063
|
+
await copyExtra("landing-page/_shared", projectDir);
|
|
1064
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1065
|
+
await copyExtra("landing-page/drizzle", projectDir);
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
// src/installers/customers.ts
|
|
1070
|
+
import fs8 from "fs/promises";
|
|
1071
|
+
import path8 from "path";
|
|
1072
|
+
|
|
1073
|
+
// src/helpers/insertAtMarker.ts
|
|
1074
|
+
import fs7 from "fs/promises";
|
|
1075
|
+
import path7 from "path";
|
|
1076
|
+
async function insertAtMarker(opts) {
|
|
1077
|
+
const { filePath, marker, content } = opts;
|
|
1078
|
+
let raw;
|
|
1079
|
+
try {
|
|
1080
|
+
raw = await fs7.readFile(filePath, "utf8");
|
|
1081
|
+
} catch {
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (raw.includes(content)) return;
|
|
1085
|
+
const next = raw.includes(marker) ? raw.replace(marker, `${content}
|
|
1086
|
+
${marker}`) : `${raw}
|
|
1087
|
+
${content}
|
|
1088
|
+
`;
|
|
1089
|
+
await fs7.writeFile(filePath, next, "utf8");
|
|
1090
|
+
}
|
|
1091
|
+
async function appendImport(opts) {
|
|
1092
|
+
const { filePath, importLine } = opts;
|
|
1093
|
+
let raw;
|
|
1094
|
+
try {
|
|
1095
|
+
raw = await fs7.readFile(filePath, "utf8");
|
|
1096
|
+
} catch {
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (raw.includes(importLine)) return;
|
|
1100
|
+
const lines = raw.split("\n");
|
|
1101
|
+
let lastImportIdx = -1;
|
|
1102
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1103
|
+
if (/^\s*import\b/.test(lines[i])) lastImportIdx = i;
|
|
1104
|
+
}
|
|
1105
|
+
if (lastImportIdx >= 0) {
|
|
1106
|
+
lines.splice(lastImportIdx + 1, 0, importLine);
|
|
1107
|
+
} else {
|
|
1108
|
+
lines.unshift(importLine);
|
|
1109
|
+
}
|
|
1110
|
+
await fs7.writeFile(filePath, lines.join("\n"), "utf8");
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// src/installers/customers.ts
|
|
1114
|
+
var installCustomers = async ({ projectDir, cfg }) => {
|
|
1115
|
+
await copyExtra("customers/_shared", projectDir);
|
|
1116
|
+
if (cfg.stack === "clerk-convex") {
|
|
1117
|
+
const fragmentPath = path8.join(
|
|
1118
|
+
templateExtrasDir,
|
|
1119
|
+
"customers",
|
|
1120
|
+
"_fragments",
|
|
1121
|
+
"convex.txt"
|
|
1122
|
+
);
|
|
1123
|
+
const fragment = await fs8.readFile(fragmentPath, "utf8");
|
|
1124
|
+
await insertAtMarker({
|
|
1125
|
+
filePath: path8.join(projectDir, "convex", "schema.ts"),
|
|
1126
|
+
marker: "// <crm-starter:tables>",
|
|
1127
|
+
content: fragment
|
|
1128
|
+
});
|
|
1129
|
+
await copyExtra("customers/convex", projectDir);
|
|
1130
|
+
} else {
|
|
1131
|
+
await copyExtra("customers/drizzle", projectDir);
|
|
1132
|
+
await insertAtMarker({
|
|
1133
|
+
filePath: path8.join(projectDir, "src", "db", "schema", "index.ts"),
|
|
1134
|
+
marker: "// <crm-starter:exports>",
|
|
1135
|
+
content: `export * from './customers';`
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
// src/installers/jobs.ts
|
|
1141
|
+
import fs9 from "fs/promises";
|
|
1142
|
+
import path9 from "path";
|
|
1143
|
+
var installJobs = async ({ projectDir, cfg, pkg }) => {
|
|
1144
|
+
await copyExtra("jobs/_shared", projectDir);
|
|
1145
|
+
if (cfg.stack === "clerk-convex") {
|
|
1146
|
+
const fragmentPath = path9.join(templateExtrasDir, "jobs", "_fragments", "convex.txt");
|
|
1147
|
+
const fragment = await fs9.readFile(fragmentPath, "utf8");
|
|
1148
|
+
await insertAtMarker({
|
|
1149
|
+
filePath: path9.join(projectDir, "convex", "schema.ts"),
|
|
1150
|
+
marker: "// <crm-starter:tables>",
|
|
1151
|
+
content: fragment
|
|
1152
|
+
});
|
|
1153
|
+
await copyExtra("jobs/convex", projectDir);
|
|
1154
|
+
} else {
|
|
1155
|
+
await copyExtra("jobs/drizzle", projectDir);
|
|
1156
|
+
await insertAtMarker({
|
|
1157
|
+
filePath: path9.join(projectDir, "src", "db", "schema", "index.ts"),
|
|
1158
|
+
marker: "// <crm-starter:exports>",
|
|
1159
|
+
content: `export * from './jobs';
|
|
1160
|
+
export * from './job-attachments';`
|
|
1161
|
+
});
|
|
1162
|
+
mergePackageJson(pkg, {
|
|
1163
|
+
dependencies: {
|
|
1164
|
+
"@aws-sdk/client-s3": "^3.717.0"
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
await mergeEnv(projectDir, {
|
|
1168
|
+
heading: "Cloudflare R2 (job photos + attachments)",
|
|
1169
|
+
vars: {
|
|
1170
|
+
R2_ACCOUNT_ID: "",
|
|
1171
|
+
R2_ACCESS_KEY_ID: "",
|
|
1172
|
+
R2_SECRET_ACCESS_KEY: "",
|
|
1173
|
+
R2_BUCKET: "",
|
|
1174
|
+
R2_PUBLIC_URL: "https://your-bucket.r2.dev"
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
// src/installers/estimates-invoices.ts
|
|
1181
|
+
import path10 from "path";
|
|
1182
|
+
var installEstimatesInvoices = async ({ projectDir, cfg }) => {
|
|
1183
|
+
await copyExtra("estimates-invoices/_shared", projectDir);
|
|
1184
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1185
|
+
await copyExtra("estimates-invoices/drizzle", projectDir);
|
|
1186
|
+
await insertAtMarker({
|
|
1187
|
+
filePath: path10.join(projectDir, "src", "db", "schema", "index.ts"),
|
|
1188
|
+
marker: "// <crm-starter:exports>",
|
|
1189
|
+
content: `export * from './estimates';
|
|
1190
|
+
export * from './invoices';`
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
// src/installers/calendar-dispatch.ts
|
|
1196
|
+
var installCalendarDispatch = async ({ projectDir, cfg, pkg }) => {
|
|
1197
|
+
await copyExtra("calendar-dispatch/_shared", projectDir);
|
|
1198
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1199
|
+
await copyExtra("calendar-dispatch/drizzle", projectDir);
|
|
1200
|
+
}
|
|
1201
|
+
mergePackageJson(pkg, {
|
|
1202
|
+
dependencies: {
|
|
1203
|
+
"@dnd-kit/core": "^6.3.1",
|
|
1204
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
1205
|
+
"date-fns": "^4.1.0"
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
// src/installers/payments-stripe.ts
|
|
1211
|
+
var installPaymentsStripe = async ({ projectDir, pkg }) => {
|
|
1212
|
+
await copyExtra("payments-stripe", projectDir);
|
|
1213
|
+
mergePackageJson(pkg, {
|
|
1214
|
+
dependencies: {
|
|
1215
|
+
stripe: "^17.4.0",
|
|
1216
|
+
"@stripe/stripe-js": "^5.3.0"
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
await mergeEnv(projectDir, {
|
|
1220
|
+
heading: "Stripe",
|
|
1221
|
+
vars: {
|
|
1222
|
+
STRIPE_SECRET_KEY: "sk_test_...",
|
|
1223
|
+
STRIPE_WEBHOOK_SECRET: "whsec_...",
|
|
1224
|
+
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: "pk_test_..."
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
// src/installers/comms-sms.ts
|
|
1230
|
+
import path11 from "path";
|
|
1231
|
+
var installCommsSms = async ({ projectDir, cfg, pkg }) => {
|
|
1232
|
+
await copyExtra("comms-sms/_shared", projectDir);
|
|
1233
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1234
|
+
await copyExtra("comms-sms/drizzle", projectDir);
|
|
1235
|
+
await insertAtMarker({
|
|
1236
|
+
filePath: path11.join(projectDir, "src", "db", "schema", "index.ts"),
|
|
1237
|
+
marker: "// <crm-starter:exports>",
|
|
1238
|
+
content: `export * from './sms-messages';`
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
mergePackageJson(pkg, {
|
|
1242
|
+
dependencies: {
|
|
1243
|
+
twilio: "^5.11.2",
|
|
1244
|
+
"libphonenumber-js": "^1.12.0"
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
await mergeEnv(projectDir, {
|
|
1248
|
+
heading: "Twilio (SMS)",
|
|
1249
|
+
vars: {
|
|
1250
|
+
TWILIO_ACCOUNT_SID: "AC...",
|
|
1251
|
+
TWILIO_AUTH_TOKEN: "...",
|
|
1252
|
+
TWILIO_PHONE_NUMBER: "+15555555555"
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
// src/installers/comms-email.ts
|
|
1258
|
+
var installCommsEmail = async ({ projectDir, pkg }) => {
|
|
1259
|
+
await copyExtra("comms-email", projectDir);
|
|
1260
|
+
mergePackageJson(pkg, {
|
|
1261
|
+
dependencies: {
|
|
1262
|
+
resend: "^4.0.0",
|
|
1263
|
+
"react-email": "^3.0.0",
|
|
1264
|
+
"@react-email/components": "^0.0.31"
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
await mergeEnv(projectDir, {
|
|
1268
|
+
heading: "Resend (email)",
|
|
1269
|
+
vars: {
|
|
1270
|
+
RESEND_API_KEY: "re_...",
|
|
1271
|
+
EMAIL_FROM: "noreply@yourbusiness.com"
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
// src/installers/reviews.ts
|
|
1277
|
+
var installReviews = async ({ projectDir }) => {
|
|
1278
|
+
await copyExtra("reviews", projectDir);
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
// src/installers/reporting.ts
|
|
1282
|
+
var installReporting = async ({ projectDir, pkg }) => {
|
|
1283
|
+
await copyExtra("reporting", projectDir);
|
|
1284
|
+
mergePackageJson(pkg, {
|
|
1285
|
+
dependencies: {
|
|
1286
|
+
recharts: "^2.13.0"
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
// src/installers/seed.ts
|
|
1292
|
+
var installSeed = async ({ projectDir, cfg, pkg }) => {
|
|
1293
|
+
await copyExtra("seed/_shared", projectDir);
|
|
1294
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1295
|
+
await copyExtra("seed/drizzle", projectDir);
|
|
1296
|
+
mergePackageJson(pkg, {
|
|
1297
|
+
dependencies: { dotenv: "^16.4.7" }
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
mergePackageJson(pkg, {
|
|
1301
|
+
devDependencies: {
|
|
1302
|
+
"@faker-js/faker": "^9.3.0",
|
|
1303
|
+
tsx: "^4.19.0"
|
|
1304
|
+
},
|
|
1305
|
+
scripts: {
|
|
1306
|
+
"db:seed": "tsx scripts/seed.ts"
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
// src/installers/price-book.ts
|
|
1312
|
+
import path12 from "path";
|
|
1313
|
+
var installPriceBook = async ({ projectDir, cfg }) => {
|
|
1314
|
+
await copyExtra("price-book/_shared", projectDir);
|
|
1315
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1316
|
+
await copyExtra("price-book/drizzle", projectDir);
|
|
1317
|
+
await insertAtMarker({
|
|
1318
|
+
filePath: path12.join(projectDir, "src", "db", "schema", "index.ts"),
|
|
1319
|
+
marker: "// <crm-starter:exports>",
|
|
1320
|
+
content: `export * from './price-book';`
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
// src/installers/customer-portal.ts
|
|
1326
|
+
var installCustomerPortal = async ({ projectDir, cfg }) => {
|
|
1327
|
+
await copyExtra("customer-portal/_shared", projectDir);
|
|
1328
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1329
|
+
await copyExtra("customer-portal/drizzle", projectDir);
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
// src/installers/service-plans.ts
|
|
1334
|
+
import path13 from "path";
|
|
1335
|
+
var installServicePlans = async ({ projectDir, cfg }) => {
|
|
1336
|
+
await copyExtra("service-plans/_shared", projectDir);
|
|
1337
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1338
|
+
await copyExtra("service-plans/drizzle", projectDir);
|
|
1339
|
+
await insertAtMarker({
|
|
1340
|
+
filePath: path13.join(projectDir, "src", "db", "schema", "index.ts"),
|
|
1341
|
+
marker: "// <crm-starter:exports>",
|
|
1342
|
+
content: `export * from './service-plans';`
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
// src/installers/checklists.ts
|
|
1348
|
+
import path14 from "path";
|
|
1349
|
+
var installChecklists = async ({ projectDir, cfg }) => {
|
|
1350
|
+
await copyExtra("checklists/_shared", projectDir);
|
|
1351
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1352
|
+
await copyExtra("checklists/drizzle", projectDir);
|
|
1353
|
+
await insertAtMarker({
|
|
1354
|
+
filePath: path14.join(projectDir, "src", "db", "schema", "index.ts"),
|
|
1355
|
+
marker: "// <crm-starter:exports>",
|
|
1356
|
+
content: `export * from './checklists';`
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
// src/installers/industry-extras.ts
|
|
1362
|
+
var installEquipmentTracking = async ({ projectDir }) => {
|
|
1363
|
+
await copyExtra("equipment-tracking", projectDir);
|
|
1364
|
+
};
|
|
1365
|
+
var installMaintenancePlans = async ({ projectDir }) => {
|
|
1366
|
+
await copyExtra("maintenance-plans", projectDir);
|
|
1367
|
+
};
|
|
1368
|
+
var installEmergencyDispatch = async ({ projectDir }) => {
|
|
1369
|
+
await copyExtra("emergency-dispatch", projectDir);
|
|
1370
|
+
};
|
|
1371
|
+
var installPermitTracking = async ({ projectDir }) => {
|
|
1372
|
+
await copyExtra("permit-tracking", projectDir);
|
|
1373
|
+
};
|
|
1374
|
+
var installInspectionChecklists = async ({ projectDir }) => {
|
|
1375
|
+
await copyExtra("inspection-checklists", projectDir);
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
// src/installers/brand.ts
|
|
1379
|
+
import fs10 from "fs/promises";
|
|
1380
|
+
import path15 from "path";
|
|
1381
|
+
function hexToHslTriplet(hex) {
|
|
1382
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
1383
|
+
if (!m) return "199 89% 48%";
|
|
1384
|
+
const value = m[1];
|
|
1385
|
+
const r = parseInt(value.slice(0, 2), 16) / 255;
|
|
1386
|
+
const g = parseInt(value.slice(2, 4), 16) / 255;
|
|
1387
|
+
const b = parseInt(value.slice(4, 6), 16) / 255;
|
|
1388
|
+
const max = Math.max(r, g, b);
|
|
1389
|
+
const min = Math.min(r, g, b);
|
|
1390
|
+
const l = (max + min) / 2;
|
|
1391
|
+
let h = 0;
|
|
1392
|
+
let s = 0;
|
|
1393
|
+
if (max !== min) {
|
|
1394
|
+
const d = max - min;
|
|
1395
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
1396
|
+
switch (max) {
|
|
1397
|
+
case r:
|
|
1398
|
+
h = (g - b) / d + (g < b ? 6 : 0);
|
|
1399
|
+
break;
|
|
1400
|
+
case g:
|
|
1401
|
+
h = (b - r) / d + 2;
|
|
1402
|
+
break;
|
|
1403
|
+
case b:
|
|
1404
|
+
h = (r - g) / d + 4;
|
|
1405
|
+
break;
|
|
1406
|
+
}
|
|
1407
|
+
h /= 6;
|
|
1408
|
+
}
|
|
1409
|
+
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
|
1410
|
+
}
|
|
1411
|
+
var installBrand = async ({ projectDir, cfg }) => {
|
|
1412
|
+
const cssPath = path15.join(projectDir, "src", "app", "globals.css");
|
|
1413
|
+
try {
|
|
1414
|
+
const css = await fs10.readFile(cssPath, "utf8");
|
|
1415
|
+
const primaryHsl = hexToHslTriplet(cfg.primaryColor);
|
|
1416
|
+
const accentHsl = hexToHslTriplet(cfg.accentColor);
|
|
1417
|
+
const next = css.replace(/--brand: [^;]+;/g, `--brand: ${primaryHsl};`).replace(/--brand-hex: [^;]+;/g, `--brand-hex: ${cfg.primaryColor};`).replace(/--brand-accent: [^;]+;/g, `--brand-accent: ${accentHsl};`).replace(/--brand-accent-hex: [^;]+;/g, `--brand-accent-hex: ${cfg.accentColor};`);
|
|
1418
|
+
await fs10.writeFile(cssPath, next, "utf8");
|
|
1419
|
+
} catch {
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
// src/installers/logo.ts
|
|
1424
|
+
import fs11 from "fs/promises";
|
|
1425
|
+
import path16 from "path";
|
|
1426
|
+
var installLogo = async ({ projectDir, cfg }) => {
|
|
1427
|
+
if (!cfg.logoPath) return;
|
|
1428
|
+
const sourcePath = cfg.logoPath.replace(/^~/, process.env.HOME ?? "");
|
|
1429
|
+
try {
|
|
1430
|
+
await fs11.access(sourcePath);
|
|
1431
|
+
} catch {
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
const ext = path16.extname(sourcePath).toLowerCase();
|
|
1435
|
+
const publicDir = path16.join(projectDir, "public");
|
|
1436
|
+
await fs11.mkdir(publicDir, { recursive: true });
|
|
1437
|
+
await fs11.copyFile(sourcePath, path16.join(publicDir, `logo${ext}`));
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
// src/installers/business-info.ts
|
|
1441
|
+
import fs12 from "fs/promises";
|
|
1442
|
+
import path17 from "path";
|
|
1443
|
+
var installBusinessInfo = async ({ projectDir, cfg }) => {
|
|
1444
|
+
const libDir = path17.join(projectDir, "src", "lib");
|
|
1445
|
+
await fs12.mkdir(libDir, { recursive: true });
|
|
1446
|
+
const businessModule = `/**
|
|
1447
|
+
* Canonical business identity + contact info \u2014 generated at scaffold time
|
|
1448
|
+
* by create-crm-starter. Edit here once; it surfaces everywhere (headers,
|
|
1449
|
+
* landing page, transactional emails, invoices, customer portal).
|
|
1450
|
+
*/
|
|
1451
|
+
export const business = {
|
|
1452
|
+
name: ${JSON.stringify(cfg.businessName)},
|
|
1453
|
+
legalName: ${JSON.stringify(cfg.businessLegalName ?? cfg.businessName)},
|
|
1454
|
+
tagline: ${JSON.stringify(cfg.tagline ?? "")},
|
|
1455
|
+
industry: ${JSON.stringify(cfg.industry)},
|
|
1456
|
+
phone: ${JSON.stringify(cfg.businessPhone ?? "")},
|
|
1457
|
+
email: ${JSON.stringify(cfg.businessEmail ?? "")},
|
|
1458
|
+
address: ${JSON.stringify(cfg.businessAddress ?? "")},
|
|
1459
|
+
serviceAreaZips: ${JSON.stringify(
|
|
1460
|
+
cfg.serviceAreaZips ? cfg.serviceAreaZips.split(",").map((z) => z.trim()).filter(Boolean) : []
|
|
1461
|
+
)},
|
|
1462
|
+
brand: {
|
|
1463
|
+
primary: ${JSON.stringify(cfg.primaryColor)},
|
|
1464
|
+
accent: ${JSON.stringify(cfg.accentColor)},
|
|
1465
|
+
logoSrc: ${JSON.stringify(cfg.logoPath ? `/logo${path17.extname(cfg.logoPath).toLowerCase()}` : "")},
|
|
1466
|
+
},
|
|
1467
|
+
} as const;
|
|
1468
|
+
|
|
1469
|
+
export type Business = typeof business;
|
|
1470
|
+
`;
|
|
1471
|
+
await fs12.writeFile(path17.join(libDir, "business.ts"), businessModule, "utf8");
|
|
1472
|
+
const layoutPath = path17.join(projectDir, "src", "app", "layout.tsx");
|
|
1473
|
+
try {
|
|
1474
|
+
const layout = await fs12.readFile(layoutPath, "utf8");
|
|
1475
|
+
const titleEscaped = cfg.businessName.replace(/'/g, "\\'");
|
|
1476
|
+
const description = cfg.tagline?.trim() ? cfg.tagline.replace(/'/g, "\\'") : `${titleEscaped} \u2014 home-services CRM`;
|
|
1477
|
+
const next = layout.replace(/title:\s*'[^']*'/, `title: '${titleEscaped}'`).replace(/description:\s*'[^']*'/, `description: '${description}'`);
|
|
1478
|
+
if (next !== layout) {
|
|
1479
|
+
await fs12.writeFile(layoutPath, next, "utf8");
|
|
1480
|
+
}
|
|
1481
|
+
} catch {
|
|
1482
|
+
}
|
|
1483
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
1484
|
+
const block = (value) => `
|
|
1485
|
+
# --- Admin bootstrap ---
|
|
1486
|
+
ADMIN_BOOTSTRAP_EMAIL=${value}
|
|
1487
|
+
`;
|
|
1488
|
+
try {
|
|
1489
|
+
await fs12.appendFile(
|
|
1490
|
+
path17.join(projectDir, ".env.local"),
|
|
1491
|
+
block(cfg.ownerEmail ?? ""),
|
|
1492
|
+
"utf8"
|
|
1493
|
+
);
|
|
1494
|
+
await fs12.appendFile(
|
|
1495
|
+
path17.join(projectDir, ".env.example"),
|
|
1496
|
+
block("owner@example.com"),
|
|
1497
|
+
"utf8"
|
|
1498
|
+
);
|
|
1499
|
+
} catch {
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
// src/installers/mobile-api.ts
|
|
1505
|
+
import fs13 from "fs/promises";
|
|
1506
|
+
import path18 from "path";
|
|
1507
|
+
|
|
1508
|
+
// src/helpers/mobileBrand.ts
|
|
1509
|
+
function mobileBrand(cfg) {
|
|
1510
|
+
const base = cfg.projectName.replace(/[^a-z0-9]/gi, "").toLowerCase() || "crmapp";
|
|
1511
|
+
return {
|
|
1512
|
+
name: `${cfg.businessName} Field`,
|
|
1513
|
+
slug: cfg.projectName,
|
|
1514
|
+
scheme: base,
|
|
1515
|
+
bundleId: `com.crmstarter.${base}`,
|
|
1516
|
+
primaryColor: cfg.primaryColor,
|
|
1517
|
+
accentColor: cfg.accentColor
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// src/installers/mobile-api.ts
|
|
1522
|
+
var installMobileApi = async ({ projectDir, cfg, pkg }) => {
|
|
1523
|
+
await copyExtra("mobile-api/drizzle", projectDir);
|
|
1524
|
+
await insertAtMarker({
|
|
1525
|
+
filePath: path18.join(projectDir, "src", "db", "schema", "index.ts"),
|
|
1526
|
+
marker: "// <crm-starter:exports>",
|
|
1527
|
+
content: `export * from './push-tokens';
|
|
1528
|
+
export * from './time-entries';`
|
|
1529
|
+
});
|
|
1530
|
+
if (cfg.estimatesInvoices && cfg.payments) {
|
|
1531
|
+
await copyExtra("mobile-api/invoices", projectDir);
|
|
1532
|
+
}
|
|
1533
|
+
if (cfg.estimatesInvoices) {
|
|
1534
|
+
await copyExtra("mobile-api/estimates", projectDir);
|
|
1535
|
+
}
|
|
1536
|
+
if (cfg.comms.includes("sms")) {
|
|
1537
|
+
await copyExtra("mobile-api/sms", projectDir);
|
|
1538
|
+
}
|
|
1539
|
+
const authPath = path18.join(projectDir, "src", "lib", "auth.ts");
|
|
1540
|
+
try {
|
|
1541
|
+
const raw = await fs13.readFile(authPath, "utf8");
|
|
1542
|
+
if (raw.includes("// <crm-starter:auth-plugins>")) {
|
|
1543
|
+
const patched = raw.replace(
|
|
1544
|
+
" plugins: [nextCookies()], // <crm-starter:auth-plugins>",
|
|
1545
|
+
[
|
|
1546
|
+
" // Mobile app deep-link origin (set MOBILE_APP_SCHEME in env).",
|
|
1547
|
+
" trustedOrigins: process.env.MOBILE_APP_SCHEME ? [process.env.MOBILE_APP_SCHEME] : [],",
|
|
1548
|
+
" plugins: [bearer(), nextCookies()],"
|
|
1549
|
+
].join("\n")
|
|
1550
|
+
);
|
|
1551
|
+
await fs13.writeFile(authPath, patched, "utf8");
|
|
1552
|
+
await appendImport({ filePath: authPath, importLine: "import { bearer } from 'better-auth/plugins';" });
|
|
1553
|
+
}
|
|
1554
|
+
} catch {
|
|
1555
|
+
}
|
|
1556
|
+
await insertAtMarker({
|
|
1557
|
+
filePath: path18.join(projectDir, "src", "lib", "calendar", "actions.ts"),
|
|
1558
|
+
marker: "// <crm-starter:assign-hook>",
|
|
1559
|
+
content: [
|
|
1560
|
+
" if (input.technicianId) {",
|
|
1561
|
+
" const { sendPushToUser } = await import('@/lib/push/send');",
|
|
1562
|
+
" await sendPushToUser(input.technicianId, {",
|
|
1563
|
+
" title: 'New job assigned',",
|
|
1564
|
+
" body: 'A job was added to your schedule.',",
|
|
1565
|
+
" data: { jobId: input.jobId },",
|
|
1566
|
+
" });",
|
|
1567
|
+
" }"
|
|
1568
|
+
].join("\n")
|
|
1569
|
+
});
|
|
1570
|
+
mergePackageJson(pkg, {
|
|
1571
|
+
dependencies: {
|
|
1572
|
+
"@aws-sdk/s3-request-presigner": "^3.717.0",
|
|
1573
|
+
"@aws-sdk/client-s3": "^3.717.0"
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1576
|
+
const { scheme } = mobileBrand(cfg);
|
|
1577
|
+
await mergeEnv(projectDir, {
|
|
1578
|
+
heading: "Mobile (field-tech iPhone app)",
|
|
1579
|
+
vars: {
|
|
1580
|
+
MOBILE_APP_SCHEME: `${scheme}://`,
|
|
1581
|
+
EXPO_ACCESS_TOKEN: ""
|
|
1582
|
+
},
|
|
1583
|
+
localDefaults: {
|
|
1584
|
+
MOBILE_APP_SCHEME: `${scheme}://`
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
// src/installers/mobile.ts
|
|
1590
|
+
import fs14 from "fs/promises";
|
|
1591
|
+
import path19 from "path";
|
|
1592
|
+
var installMobile = async ({ projectDir, cfg }) => {
|
|
1593
|
+
await copyExtra("mobile", projectDir);
|
|
1594
|
+
const mobileDir = path19.join(projectDir, "mobile");
|
|
1595
|
+
const brand = mobileBrand(cfg);
|
|
1596
|
+
const brandModule = `/**
|
|
1597
|
+
* Per-client branding for the field-tech app \u2014 generated by
|
|
1598
|
+
* create-crm-starter from your scaffold inputs. Re-run the scaffolder (or
|
|
1599
|
+
* edit your inputs) to rebrand; change the bundleId prefix to your Apple
|
|
1600
|
+
* Developer team's reverse-domain before submitting to the App Store.
|
|
1601
|
+
*/
|
|
1602
|
+
export const brand = {
|
|
1603
|
+
name: ${JSON.stringify(brand.name)},
|
|
1604
|
+
slug: ${JSON.stringify(brand.slug)},
|
|
1605
|
+
scheme: ${JSON.stringify(brand.scheme)},
|
|
1606
|
+
bundleId: ${JSON.stringify(brand.bundleId)},
|
|
1607
|
+
primaryColor: ${JSON.stringify(brand.primaryColor)},
|
|
1608
|
+
accentColor: ${JSON.stringify(brand.accentColor)},
|
|
1609
|
+
} as const;
|
|
1610
|
+
`;
|
|
1611
|
+
await fs14.writeFile(path19.join(mobileDir, "app.brand.ts"), brandModule, "utf8");
|
|
1612
|
+
const featuresModule = `/**
|
|
1613
|
+
* Which optional features this client's CRM exposes \u2014 generated by
|
|
1614
|
+
* create-crm-starter from your scaffold choices. The app hides buttons/tabs
|
|
1615
|
+
* for features the backend doesn't have. (Checklists + time tracking are
|
|
1616
|
+
* always available.)
|
|
1617
|
+
*/
|
|
1618
|
+
export const features = {
|
|
1619
|
+
invoices: ${cfg.estimatesInvoices && cfg.payments},
|
|
1620
|
+
estimates: ${cfg.estimatesInvoices},
|
|
1621
|
+
sms: ${cfg.comms.includes("sms")},
|
|
1622
|
+
} as const;
|
|
1623
|
+
`;
|
|
1624
|
+
await fs14.writeFile(path19.join(mobileDir, "app.features.ts"), featuresModule, "utf8");
|
|
1625
|
+
await fs14.writeFile(
|
|
1626
|
+
path19.join(mobileDir, ".env"),
|
|
1627
|
+
`EXPO_PUBLIC_API_URL=https://your-client-domain.com
|
|
1628
|
+
`,
|
|
1629
|
+
"utf8"
|
|
1630
|
+
);
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
// src/installers/readme.ts
|
|
1634
|
+
import fs15 from "fs/promises";
|
|
1635
|
+
import path20 from "path";
|
|
1636
|
+
|
|
1637
|
+
// src/helpers/envSchema.ts
|
|
1638
|
+
var ENV_SCHEMA = {
|
|
1639
|
+
"auth-clerk": [
|
|
1640
|
+
{
|
|
1641
|
+
key: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
|
|
1642
|
+
label: "Clerk Publishable Key",
|
|
1643
|
+
description: "Public key shared with the browser (pk_test_\u2026 or pk_live_\u2026)",
|
|
1644
|
+
required: true,
|
|
1645
|
+
example: "pk_test_\u2026",
|
|
1646
|
+
helpUrl: "https://dashboard.clerk.com/last-active?path=api-keys",
|
|
1647
|
+
category: "auth"
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
key: "CLERK_SECRET_KEY",
|
|
1651
|
+
label: "Clerk Secret Key",
|
|
1652
|
+
description: "Server-only key, sk_test_\u2026 or sk_live_\u2026",
|
|
1653
|
+
required: true,
|
|
1654
|
+
example: "sk_test_\u2026",
|
|
1655
|
+
helpUrl: "https://dashboard.clerk.com/last-active?path=api-keys",
|
|
1656
|
+
category: "auth"
|
|
1657
|
+
},
|
|
1658
|
+
{
|
|
1659
|
+
key: "NEXT_PUBLIC_CLERK_SIGN_IN_URL",
|
|
1660
|
+
label: "Clerk sign-in URL",
|
|
1661
|
+
description: "Where unauthed users get redirected. /sign-in is the default route.",
|
|
1662
|
+
required: false,
|
|
1663
|
+
example: "/sign-in",
|
|
1664
|
+
category: "auth"
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
key: "NEXT_PUBLIC_CLERK_SIGN_UP_URL",
|
|
1668
|
+
label: "Clerk sign-up URL",
|
|
1669
|
+
description: "Where new users land. /sign-up is the default route.",
|
|
1670
|
+
required: false,
|
|
1671
|
+
example: "/sign-up",
|
|
1672
|
+
category: "auth"
|
|
1673
|
+
}
|
|
1674
|
+
],
|
|
1675
|
+
"auth-better-auth": [
|
|
1676
|
+
{
|
|
1677
|
+
key: "BETTER_AUTH_SECRET",
|
|
1678
|
+
label: "Better-Auth signing secret",
|
|
1679
|
+
description: "32+ random bytes used to sign session JWTs. Auto-generated at scaffold time \u2014 keep it secret.",
|
|
1680
|
+
required: true,
|
|
1681
|
+
example: "<32-byte hex string>",
|
|
1682
|
+
category: "auth"
|
|
1683
|
+
},
|
|
1684
|
+
{
|
|
1685
|
+
key: "BETTER_AUTH_URL",
|
|
1686
|
+
label: "App URL",
|
|
1687
|
+
description: "Full https URL of the deployed app \u2014 needed for OAuth callbacks.",
|
|
1688
|
+
required: true,
|
|
1689
|
+
example: "https://your-domain.com",
|
|
1690
|
+
category: "auth"
|
|
1691
|
+
},
|
|
1692
|
+
{
|
|
1693
|
+
key: "NEXT_PUBLIC_BETTER_AUTH_URL",
|
|
1694
|
+
label: "App URL (public)",
|
|
1695
|
+
description: "Same as BETTER_AUTH_URL but exposed to the browser for client-side auth helpers.",
|
|
1696
|
+
required: true,
|
|
1697
|
+
example: "https://your-domain.com",
|
|
1698
|
+
category: "auth"
|
|
1699
|
+
}
|
|
1700
|
+
],
|
|
1701
|
+
"db-convex": [
|
|
1702
|
+
{
|
|
1703
|
+
key: "NEXT_PUBLIC_CONVEX_URL",
|
|
1704
|
+
label: "Convex deployment URL",
|
|
1705
|
+
description: "Get this by running `npx convex dev` once \u2014 it creates a deployment and prints the URL.",
|
|
1706
|
+
required: true,
|
|
1707
|
+
example: "https://<your-deployment>.convex.cloud",
|
|
1708
|
+
helpUrl: "https://docs.convex.dev/dashboard/deployments",
|
|
1709
|
+
category: "database"
|
|
1710
|
+
}
|
|
1711
|
+
],
|
|
1712
|
+
"db-drizzle-pg": [
|
|
1713
|
+
{
|
|
1714
|
+
key: "DATABASE_URL",
|
|
1715
|
+
label: "Postgres connection string",
|
|
1716
|
+
description: "Use Neon, Supabase, RDS, or the included docker-compose for local dev.",
|
|
1717
|
+
required: true,
|
|
1718
|
+
example: "postgres://user:pass@host:5432/dbname",
|
|
1719
|
+
helpUrl: "https://neon.tech",
|
|
1720
|
+
category: "database"
|
|
1721
|
+
}
|
|
1722
|
+
],
|
|
1723
|
+
"payments-stripe": [
|
|
1724
|
+
{
|
|
1725
|
+
key: "STRIPE_SECRET_KEY",
|
|
1726
|
+
label: "Stripe Secret Key",
|
|
1727
|
+
description: "Server-only key, sk_test_\u2026 or sk_live_\u2026",
|
|
1728
|
+
required: true,
|
|
1729
|
+
example: "sk_test_\u2026",
|
|
1730
|
+
helpUrl: "https://dashboard.stripe.com/apikeys",
|
|
1731
|
+
category: "payments"
|
|
1732
|
+
},
|
|
1733
|
+
{
|
|
1734
|
+
key: "STRIPE_WEBHOOK_SECRET",
|
|
1735
|
+
label: "Stripe Webhook Signing Secret",
|
|
1736
|
+
description: "whsec_\u2026 \u2014 get it from the Webhook endpoint settings in Stripe.",
|
|
1737
|
+
required: true,
|
|
1738
|
+
example: "whsec_\u2026",
|
|
1739
|
+
helpUrl: "https://dashboard.stripe.com/webhooks",
|
|
1740
|
+
category: "payments"
|
|
1741
|
+
},
|
|
1742
|
+
{
|
|
1743
|
+
key: "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
|
|
1744
|
+
label: "Stripe Publishable Key",
|
|
1745
|
+
description: "Public key for browser, pk_test_\u2026 or pk_live_\u2026",
|
|
1746
|
+
required: true,
|
|
1747
|
+
example: "pk_test_\u2026",
|
|
1748
|
+
helpUrl: "https://dashboard.stripe.com/apikeys",
|
|
1749
|
+
category: "payments"
|
|
1750
|
+
}
|
|
1751
|
+
],
|
|
1752
|
+
"comms-sms": [
|
|
1753
|
+
{
|
|
1754
|
+
key: "TWILIO_ACCOUNT_SID",
|
|
1755
|
+
label: "Twilio Account SID",
|
|
1756
|
+
description: "AC\u2026 \u2014 find it on the Twilio console dashboard.",
|
|
1757
|
+
required: true,
|
|
1758
|
+
example: "AC\u2026",
|
|
1759
|
+
helpUrl: "https://console.twilio.com",
|
|
1760
|
+
category: "comms"
|
|
1761
|
+
},
|
|
1762
|
+
{
|
|
1763
|
+
key: "TWILIO_AUTH_TOKEN",
|
|
1764
|
+
label: "Twilio Auth Token",
|
|
1765
|
+
description: "Server-only Twilio auth token.",
|
|
1766
|
+
required: true,
|
|
1767
|
+
example: "<auth token>",
|
|
1768
|
+
helpUrl: "https://console.twilio.com",
|
|
1769
|
+
category: "comms"
|
|
1770
|
+
},
|
|
1771
|
+
{
|
|
1772
|
+
key: "TWILIO_PHONE_NUMBER",
|
|
1773
|
+
label: "Twilio Phone Number",
|
|
1774
|
+
description: "The number SMS will be sent from + the number customers reply to. E.164 (+1\u2026).",
|
|
1775
|
+
required: true,
|
|
1776
|
+
example: "+15125550100",
|
|
1777
|
+
helpUrl: "https://console.twilio.com/us1/develop/phone-numbers/manage/active",
|
|
1778
|
+
category: "comms"
|
|
1779
|
+
}
|
|
1780
|
+
],
|
|
1781
|
+
"comms-email": [
|
|
1782
|
+
{
|
|
1783
|
+
key: "RESEND_API_KEY",
|
|
1784
|
+
label: "Resend API Key",
|
|
1785
|
+
description: "re_\u2026 \u2014 server-only.",
|
|
1786
|
+
required: true,
|
|
1787
|
+
example: "re_\u2026",
|
|
1788
|
+
helpUrl: "https://resend.com/api-keys",
|
|
1789
|
+
category: "comms"
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
key: "EMAIL_FROM",
|
|
1793
|
+
label: "Sender email",
|
|
1794
|
+
description: "A verified email at your sending domain (e.g. hello@yourbusiness.com).",
|
|
1795
|
+
required: true,
|
|
1796
|
+
example: "hello@yourbusiness.com",
|
|
1797
|
+
helpUrl: "https://resend.com/domains",
|
|
1798
|
+
category: "comms"
|
|
1799
|
+
}
|
|
1800
|
+
],
|
|
1801
|
+
jobs: [
|
|
1802
|
+
{
|
|
1803
|
+
key: "R2_ACCOUNT_ID",
|
|
1804
|
+
label: "Cloudflare account ID",
|
|
1805
|
+
description: "Used for job photo uploads. Find it in the Cloudflare R2 dashboard.",
|
|
1806
|
+
required: false,
|
|
1807
|
+
helpUrl: "https://dash.cloudflare.com/?to=/:account/r2",
|
|
1808
|
+
category: "storage"
|
|
1809
|
+
},
|
|
1810
|
+
{
|
|
1811
|
+
key: "R2_ACCESS_KEY_ID",
|
|
1812
|
+
label: "R2 API token access key",
|
|
1813
|
+
description: "Create an R2 API token with read+write permission, scope to one bucket.",
|
|
1814
|
+
required: false,
|
|
1815
|
+
category: "storage"
|
|
1816
|
+
},
|
|
1817
|
+
{
|
|
1818
|
+
key: "R2_SECRET_ACCESS_KEY",
|
|
1819
|
+
label: "R2 API token secret",
|
|
1820
|
+
description: "Shown once when creating the token \u2014 store it securely.",
|
|
1821
|
+
required: false,
|
|
1822
|
+
category: "storage"
|
|
1823
|
+
},
|
|
1824
|
+
{
|
|
1825
|
+
key: "R2_BUCKET",
|
|
1826
|
+
label: "R2 bucket name",
|
|
1827
|
+
description: "Just the name (no slashes).",
|
|
1828
|
+
required: false,
|
|
1829
|
+
example: "my-crm-uploads",
|
|
1830
|
+
category: "storage"
|
|
1831
|
+
},
|
|
1832
|
+
{
|
|
1833
|
+
key: "R2_PUBLIC_URL",
|
|
1834
|
+
label: "R2 public URL",
|
|
1835
|
+
description: "Public URL for the bucket (custom domain or pub-\u2026 URL). Required for rendering uploaded images.",
|
|
1836
|
+
required: false,
|
|
1837
|
+
example: "https://pub-\u2026.r2.dev",
|
|
1838
|
+
category: "storage"
|
|
1839
|
+
}
|
|
1840
|
+
],
|
|
1841
|
+
"mobile-api": [
|
|
1842
|
+
{
|
|
1843
|
+
key: "MOBILE_APP_SCHEME",
|
|
1844
|
+
label: "Mobile app URL scheme",
|
|
1845
|
+
description: "Deep-link scheme of the field-tech app (e.g. yourapp://). Registered as a trusted origin for token auth. Auto-set at scaffold time.",
|
|
1846
|
+
required: false,
|
|
1847
|
+
example: "yourapp://",
|
|
1848
|
+
category: "mobile"
|
|
1849
|
+
},
|
|
1850
|
+
{
|
|
1851
|
+
key: "EXPO_ACCESS_TOKEN",
|
|
1852
|
+
label: "Expo access token (optional)",
|
|
1853
|
+
description: "Only needed for enhanced push-notification security. Basic Expo push works without it.",
|
|
1854
|
+
required: false,
|
|
1855
|
+
helpUrl: "https://docs.expo.dev/push-notifications/sending-notifications/",
|
|
1856
|
+
category: "mobile"
|
|
1857
|
+
}
|
|
1858
|
+
]
|
|
1859
|
+
};
|
|
1860
|
+
function getEnvVarsForConfig(cfg) {
|
|
1861
|
+
const modules = [];
|
|
1862
|
+
if (cfg.stack === "clerk-convex") {
|
|
1863
|
+
modules.push("auth-clerk", "db-convex");
|
|
1864
|
+
} else {
|
|
1865
|
+
modules.push("auth-better-auth", "db-drizzle-pg");
|
|
1866
|
+
}
|
|
1867
|
+
if (cfg.payments) modules.push("payments-stripe");
|
|
1868
|
+
if (cfg.comms.includes("sms")) modules.push("comms-sms");
|
|
1869
|
+
if (cfg.comms.includes("email")) modules.push("comms-email");
|
|
1870
|
+
if (cfg.jobs) modules.push("jobs");
|
|
1871
|
+
if (cfg.mobileApp && cfg.stack === "better-auth-drizzle" && cfg.jobs && cfg.customers) {
|
|
1872
|
+
modules.push("mobile-api");
|
|
1873
|
+
}
|
|
1874
|
+
const out = [];
|
|
1875
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1876
|
+
for (const m of modules) {
|
|
1877
|
+
for (const spec of ENV_SCHEMA[m] ?? []) {
|
|
1878
|
+
if (seen.has(spec.key)) continue;
|
|
1879
|
+
seen.add(spec.key);
|
|
1880
|
+
out.push(spec);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
return out;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// src/helpers/vercelDeployUrl.ts
|
|
1887
|
+
function buildVercelDeployUrl(opts) {
|
|
1888
|
+
const required = getEnvVarsForConfig(opts.cfg).filter((v) => v.required);
|
|
1889
|
+
const params = new URLSearchParams();
|
|
1890
|
+
if (opts.repositoryUrl) params.set("repository-url", opts.repositoryUrl);
|
|
1891
|
+
params.set("project-name", opts.projectName ?? opts.cfg.projectName);
|
|
1892
|
+
if (required.length > 0) {
|
|
1893
|
+
params.set("env", required.map((v) => v.key).join(","));
|
|
1894
|
+
params.set(
|
|
1895
|
+
"envDescription",
|
|
1896
|
+
required.map((v) => `${v.key} \u2014 ${v.description}`).join(" \xB7 ")
|
|
1897
|
+
);
|
|
1898
|
+
if (opts.repositoryUrl) {
|
|
1899
|
+
const trimmed = opts.repositoryUrl.replace(/\.git$/, "");
|
|
1900
|
+
params.set("envLink", `${trimmed}#environment-variables`);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return `https://vercel.com/new/clone?${params.toString()}`;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// src/installers/readme.ts
|
|
1907
|
+
var installReadme = async ({ projectDir, cfg }) => {
|
|
1908
|
+
const vars = getEnvVarsForConfig(cfg);
|
|
1909
|
+
const byCategory = groupByCategory(vars);
|
|
1910
|
+
const isConvex = cfg.stack === "clerk-convex";
|
|
1911
|
+
const preview = (label) => isConvex ? `${label} _(preview \u2014 sample data on Convex)_` : label;
|
|
1912
|
+
const features = [];
|
|
1913
|
+
if (cfg.landingPage) features.push("Public landing page" + (cfg.onlineBooking ? " with online booking widget" : ""));
|
|
1914
|
+
if (cfg.customers) features.push("Customers / contacts management");
|
|
1915
|
+
if (cfg.jobs) features.push("Jobs with status pipeline + line items + photos");
|
|
1916
|
+
if (cfg.estimatesInvoices) features.push(preview("Estimates + invoices with Stripe payment links"));
|
|
1917
|
+
if (cfg.calendarDispatch) features.push("Drag-drop dispatch calendar");
|
|
1918
|
+
if (cfg.payments) features.push("Stripe Checkout (one-off + recurring)");
|
|
1919
|
+
if (cfg.comms.includes("sms")) features.push(preview("Two-way SMS inbox (Twilio)"));
|
|
1920
|
+
if (cfg.comms.includes("email")) features.push("Transactional email (Resend)");
|
|
1921
|
+
if (cfg.reviews) features.push("Review collection");
|
|
1922
|
+
if (cfg.reporting) features.push("Reporting dashboard");
|
|
1923
|
+
if (cfg.mobileApp) features.push("Field-tech iPhone app (Expo) \u2014 see `mobile/README.md`");
|
|
1924
|
+
if (!isConvex && cfg.customers && cfg.jobs && cfg.estimatesInvoices) {
|
|
1925
|
+
features.push("Customer portal at `/portal/[token]`");
|
|
1926
|
+
}
|
|
1927
|
+
for (const extra of cfg.industryExtras) {
|
|
1928
|
+
features.push(`Industry module: ${extra}`);
|
|
1929
|
+
}
|
|
1930
|
+
const setupSteps = buildSetupSteps(cfg);
|
|
1931
|
+
const deployUrl = buildVercelDeployUrl({ cfg });
|
|
1932
|
+
const businessLine = cfg.businessLegalName && cfg.businessLegalName !== cfg.businessName ? `**${cfg.businessName}** *(${cfg.businessLegalName})*` : `**${cfg.businessName}**`;
|
|
1933
|
+
const readme = `# ${cfg.businessName}
|
|
1934
|
+
|
|
1935
|
+
${cfg.tagline ?? `Custom CRM for ${cfg.businessName}.`}
|
|
1936
|
+
|
|
1937
|
+
${businessLine}${cfg.businessPhone ? ` \xB7 ${cfg.businessPhone}` : ""}${cfg.businessEmail ? ` \xB7 ${cfg.businessEmail}` : ""}
|
|
1938
|
+
|
|
1939
|
+
---
|
|
1940
|
+
|
|
1941
|
+
## \u26A1 One-click deploy
|
|
1942
|
+
|
|
1943
|
+
[](${deployUrl})
|
|
1944
|
+
|
|
1945
|
+
After deploy, fill in the env vars below \u2014 see [Environment variables](#environment-variables) for full descriptions.
|
|
1946
|
+
|
|
1947
|
+
---
|
|
1948
|
+
|
|
1949
|
+
## \u{1F6E0} Local dev
|
|
1950
|
+
|
|
1951
|
+
\`\`\`bash
|
|
1952
|
+
pnpm install
|
|
1953
|
+
pnpm setup # interactive env wizard \u2014 fills in .env.local
|
|
1954
|
+
${cfg.stack === "better-auth-drizzle" ? "pnpm db:push # apply schema to your Postgres" : "pnpm convex:dev # first run prompts you to create a Convex deployment"}
|
|
1955
|
+
${cfg.seed ? "pnpm db:seed # populate realistic demo data\n" : ""}pnpm dev # http://localhost:3000
|
|
1956
|
+
\`\`\`
|
|
1957
|
+
|
|
1958
|
+
${cfg.ownerName || cfg.ownerEmail ? `First admin to seed:
|
|
1959
|
+
- Name: ${cfg.ownerName ?? "(not set)"}
|
|
1960
|
+
- Email: ${cfg.ownerEmail ?? "(not set)"}
|
|
1961
|
+
|
|
1962
|
+
Sign up with that email at \`/sign-up\` to get admin access.
|
|
1963
|
+
` : ""}
|
|
1964
|
+
---
|
|
1965
|
+
|
|
1966
|
+
## \u2728 What's in the box
|
|
1967
|
+
|
|
1968
|
+
${features.map((f) => `- ${f}`).join("\n")}
|
|
1969
|
+
|
|
1970
|
+
Stack: ${stackLabel(cfg.stack)}.
|
|
1971
|
+
|
|
1972
|
+
---
|
|
1973
|
+
|
|
1974
|
+
## \u{1F510} Environment variables
|
|
1975
|
+
|
|
1976
|
+
${vars.length === 0 ? "_No external integrations required._" : Object.entries(byCategory).map(
|
|
1977
|
+
([category, list]) => `### ${categoryLabel(category)}
|
|
1978
|
+
|
|
1979
|
+
${list.map(
|
|
1980
|
+
(v) => `- **${v.key}** ${v.required ? "*(required)*" : "*(optional)*"}
|
|
1981
|
+
${v.description}${v.example ? `
|
|
1982
|
+
- Example: \`${v.example}\`` : ""}${v.helpUrl ? `
|
|
1983
|
+
- Get it: [${v.helpUrl}](${v.helpUrl})` : ""}`
|
|
1984
|
+
).join("\n")}`
|
|
1985
|
+
).join("\n\n")}
|
|
1986
|
+
|
|
1987
|
+
Set them in \`.env.local\` for local dev. The \`pnpm setup\` script walks you through them interactively.
|
|
1988
|
+
|
|
1989
|
+
---
|
|
1990
|
+
|
|
1991
|
+
## \u{1F680} Hand-off checklist
|
|
1992
|
+
|
|
1993
|
+
${setupSteps.map((s, i) => `${i + 1}. ${s}`).join("\n")}
|
|
1994
|
+
|
|
1995
|
+
---
|
|
1996
|
+
|
|
1997
|
+
## \u{1F4E6} Generated with [create-crm-starter](https://github.com/dougallen/crm-starter)
|
|
1998
|
+
|
|
1999
|
+
Industry preset: **${cfg.industry}** \xB7 Deploy target: **${cfg.deployTarget}** \xB7 Brand: ${cfg.primaryColor}${cfg.accentColor ? ` + ${cfg.accentColor}` : ""}
|
|
2000
|
+
`;
|
|
2001
|
+
await fs15.writeFile(path20.join(projectDir, "README.md"), readme, "utf8");
|
|
2002
|
+
};
|
|
2003
|
+
function groupByCategory(vars) {
|
|
2004
|
+
const out = {};
|
|
2005
|
+
for (const v of vars) {
|
|
2006
|
+
(out[v.category] ??= []).push(v);
|
|
2007
|
+
}
|
|
2008
|
+
return out;
|
|
2009
|
+
}
|
|
2010
|
+
function categoryLabel(cat) {
|
|
2011
|
+
switch (cat) {
|
|
2012
|
+
case "auth":
|
|
2013
|
+
return "Authentication";
|
|
2014
|
+
case "database":
|
|
2015
|
+
return "Database";
|
|
2016
|
+
case "payments":
|
|
2017
|
+
return "Payments (Stripe)";
|
|
2018
|
+
case "comms":
|
|
2019
|
+
return "Communications";
|
|
2020
|
+
case "storage":
|
|
2021
|
+
return "File storage";
|
|
2022
|
+
case "ai":
|
|
2023
|
+
return "AI";
|
|
2024
|
+
case "mobile":
|
|
2025
|
+
return "Mobile app";
|
|
2026
|
+
default:
|
|
2027
|
+
return cat;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
function stackLabel(stack) {
|
|
2031
|
+
return stack === "clerk-convex" ? "Next.js 15 + Clerk + Convex" : "Next.js 15 + Better-Auth + Postgres + Drizzle";
|
|
2032
|
+
}
|
|
2033
|
+
function buildSetupSteps(cfg) {
|
|
2034
|
+
const steps = [
|
|
2035
|
+
"Register a domain and point it at Vercel.",
|
|
2036
|
+
"Click the Deploy button above; fill in environment variables when prompted."
|
|
2037
|
+
];
|
|
2038
|
+
if (cfg.stack === "better-auth-drizzle") {
|
|
2039
|
+
steps.push("Provision a Postgres database (Neon recommended) and add `DATABASE_URL`.");
|
|
2040
|
+
steps.push("Run `pnpm db:push` to apply schema.");
|
|
2041
|
+
} else {
|
|
2042
|
+
steps.push("Run `npx convex dev` to provision a Convex deployment and copy its URL into `NEXT_PUBLIC_CONVEX_URL`.");
|
|
2043
|
+
}
|
|
2044
|
+
if (cfg.payments) {
|
|
2045
|
+
steps.push("Create Stripe webhook at `https://<your-domain>/api/stripe/webhook` \u2014 copy the signing secret into `STRIPE_WEBHOOK_SECRET`.");
|
|
2046
|
+
}
|
|
2047
|
+
if (cfg.comms.includes("sms")) {
|
|
2048
|
+
steps.push('Buy a Twilio phone number and set its "A message comes in" webhook to `https://<your-domain>/api/twilio/sms`.');
|
|
2049
|
+
}
|
|
2050
|
+
if (cfg.comms.includes("email")) {
|
|
2051
|
+
steps.push("Verify your sending domain in Resend and set `EMAIL_FROM` to a verified address.");
|
|
2052
|
+
}
|
|
2053
|
+
if (cfg.jobs) {
|
|
2054
|
+
steps.push("(Optional) Create a Cloudflare R2 bucket for job photo uploads \u2014 set the R2_* env vars.");
|
|
2055
|
+
}
|
|
2056
|
+
steps.push("Sign up via `/sign-up` to create the first admin user.");
|
|
2057
|
+
steps.push("Set up the price book, default checklists, and any service plans before handing off to staff.");
|
|
2058
|
+
if (cfg.mobileApp) {
|
|
2059
|
+
steps.push("Build + ship the field-tech iPhone app: set `mobile/.env` `EXPO_PUBLIC_API_URL` to this deployed URL, then follow `mobile/README.md` (EAS build \u2192 App Store).");
|
|
2060
|
+
}
|
|
2061
|
+
return steps;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// src/installers/index.ts
|
|
2065
|
+
var installers = {
|
|
2066
|
+
"auth-clerk": installAuthClerk,
|
|
2067
|
+
"db-convex": installDbConvex,
|
|
2068
|
+
"auth-better-auth": installAuthBetterAuth,
|
|
2069
|
+
"db-drizzle-pg": installDbDrizzlePg,
|
|
2070
|
+
"landing-page": installLandingPage,
|
|
2071
|
+
customers: installCustomers,
|
|
2072
|
+
jobs: installJobs,
|
|
2073
|
+
"estimates-invoices": installEstimatesInvoices,
|
|
2074
|
+
"calendar-dispatch": installCalendarDispatch,
|
|
2075
|
+
"payments-stripe": installPaymentsStripe,
|
|
2076
|
+
"comms-sms": installCommsSms,
|
|
2077
|
+
"comms-email": installCommsEmail,
|
|
2078
|
+
reviews: installReviews,
|
|
2079
|
+
reporting: installReporting,
|
|
2080
|
+
seed: installSeed,
|
|
2081
|
+
"price-book": installPriceBook,
|
|
2082
|
+
"customer-portal": installCustomerPortal,
|
|
2083
|
+
"service-plans": installServicePlans,
|
|
2084
|
+
checklists: installChecklists,
|
|
2085
|
+
"equipment-tracking": installEquipmentTracking,
|
|
2086
|
+
"maintenance-plans": installMaintenancePlans,
|
|
2087
|
+
"emergency-dispatch": installEmergencyDispatch,
|
|
2088
|
+
"permit-tracking": installPermitTracking,
|
|
2089
|
+
"inspection-checklists": installInspectionChecklists,
|
|
2090
|
+
brand: installBrand,
|
|
2091
|
+
logo: installLogo,
|
|
2092
|
+
"business-info": installBusinessInfo,
|
|
2093
|
+
"mobile-api": installMobileApi,
|
|
2094
|
+
mobile: installMobile,
|
|
2095
|
+
readme: installReadme
|
|
2096
|
+
};
|
|
2097
|
+
function resolveInstallers(cfg) {
|
|
2098
|
+
const list = [];
|
|
2099
|
+
if (cfg.stack === "clerk-convex") {
|
|
2100
|
+
list.push("auth-clerk", "db-convex");
|
|
2101
|
+
} else {
|
|
2102
|
+
list.push("auth-better-auth", "db-drizzle-pg");
|
|
2103
|
+
}
|
|
2104
|
+
if (cfg.landingPage) list.push("landing-page");
|
|
2105
|
+
if (cfg.customers) list.push("customers");
|
|
2106
|
+
if (cfg.jobs) list.push("jobs");
|
|
2107
|
+
if (cfg.jobs || cfg.estimatesInvoices) list.push("price-book");
|
|
2108
|
+
if (cfg.calendarDispatch) list.push("calendar-dispatch");
|
|
2109
|
+
if (cfg.payments) list.push("payments-stripe");
|
|
2110
|
+
if (cfg.estimatesInvoices) list.push("estimates-invoices");
|
|
2111
|
+
if (cfg.stack === "better-auth-drizzle" && cfg.customers && cfg.jobs && cfg.estimatesInvoices) {
|
|
2112
|
+
list.push("customer-portal");
|
|
2113
|
+
}
|
|
2114
|
+
if (cfg.industryExtras.includes("maintenance-plans") || cfg.industryExtras.includes("service-plans")) {
|
|
2115
|
+
list.push("service-plans");
|
|
2116
|
+
}
|
|
2117
|
+
if (cfg.jobs) list.push("checklists");
|
|
2118
|
+
if (cfg.comms.includes("sms")) list.push("comms-sms");
|
|
2119
|
+
if (cfg.comms.includes("email")) list.push("comms-email");
|
|
2120
|
+
if (cfg.reviews) list.push("reviews");
|
|
2121
|
+
if (cfg.reporting) list.push("reporting");
|
|
2122
|
+
for (const extra of cfg.industryExtras) list.push(extra);
|
|
2123
|
+
if (cfg.seed) list.push("seed");
|
|
2124
|
+
if (cfg.mobileApp && cfg.stack === "better-auth-drizzle" && cfg.jobs && cfg.customers) {
|
|
2125
|
+
list.push("mobile-api", "mobile");
|
|
2126
|
+
}
|
|
2127
|
+
list.push("brand");
|
|
2128
|
+
if (cfg.logoPath) list.push("logo");
|
|
2129
|
+
list.push("business-info");
|
|
2130
|
+
list.push("readme");
|
|
2131
|
+
return list;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// src/commands/create.ts
|
|
2135
|
+
async function create(args) {
|
|
2136
|
+
intro(pc3.bgCyan(pc3.black(" create-crm-starter ")));
|
|
2137
|
+
const cfg = await runPrompts({
|
|
2138
|
+
projectName: args.projectName,
|
|
2139
|
+
configPath: args.configPath
|
|
2140
|
+
});
|
|
2141
|
+
note3(summarizeConfig(cfg), "Configuration");
|
|
2142
|
+
const projectDir = path21.resolve(process.cwd(), cfg.projectName);
|
|
2143
|
+
const copySpin = spinner2();
|
|
2144
|
+
copySpin.start("Copying base template");
|
|
2145
|
+
await fs16.mkdir(projectDir, { recursive: true });
|
|
2146
|
+
await copyBase(projectDir);
|
|
2147
|
+
await ensureEnvFiles(projectDir);
|
|
2148
|
+
const pkg = await readPackageJson(projectDir);
|
|
2149
|
+
setProjectName(pkg, cfg.projectName);
|
|
2150
|
+
copySpin.stop("Base template copied");
|
|
2151
|
+
const installerNames = resolveInstallers(cfg);
|
|
2152
|
+
const missing = [];
|
|
2153
|
+
for (const name of installerNames) {
|
|
2154
|
+
const installer = installers[name];
|
|
2155
|
+
if (!installer) {
|
|
2156
|
+
missing.push(name);
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
const s = spinner2();
|
|
2160
|
+
s.start(`Adding ${name}`);
|
|
2161
|
+
await installer({ projectDir, cfg, pkg });
|
|
2162
|
+
s.stop(`Added ${name}`);
|
|
2163
|
+
}
|
|
2164
|
+
await writePackageJson(projectDir, pkg);
|
|
2165
|
+
if (missing.length > 0) {
|
|
2166
|
+
log.warn(
|
|
2167
|
+
`Modules selected but not yet implemented (stub-skipped): ${missing.join(", ")}`
|
|
2168
|
+
);
|
|
2169
|
+
}
|
|
2170
|
+
const pm = cfg.install ? await installDeps(projectDir) : await detectPackageManager();
|
|
2171
|
+
if (cfg.initGit) {
|
|
2172
|
+
const gitSpin = spinner2();
|
|
2173
|
+
gitSpin.start("Initializing git repository");
|
|
2174
|
+
const initialized = await initGit(projectDir);
|
|
2175
|
+
gitSpin.stop(initialized ? "Git repository initialized" : "Skipped git (already inside a repo)");
|
|
2176
|
+
}
|
|
2177
|
+
if (cfg.stack === "clerk-convex") {
|
|
2178
|
+
const PREVIEW_ON_CONVEX = {
|
|
2179
|
+
"estimates-invoices": "Estimates & invoices",
|
|
2180
|
+
"price-book": "Price book",
|
|
2181
|
+
"service-plans": "Service plans",
|
|
2182
|
+
checklists: "Job checklists",
|
|
2183
|
+
"comms-sms": "SMS sending + inbox"
|
|
2184
|
+
};
|
|
2185
|
+
const previews = installerNames.filter((n) => n in PREVIEW_ON_CONVEX).map((n) => PREVIEW_ON_CONVEX[n]);
|
|
2186
|
+
if (previews.length > 0) {
|
|
2187
|
+
note3(
|
|
2188
|
+
[
|
|
2189
|
+
pc3.yellow("Heads up \u2014 these render with sample data on the Convex stack:"),
|
|
2190
|
+
...previews.map((p) => ` \u2022 ${p}`),
|
|
2191
|
+
"",
|
|
2192
|
+
pc3.dim("customers + jobs are fully working on Convex. The rest have"),
|
|
2193
|
+
pc3.dim("real backends only on the Better-Auth + Drizzle stack for now.")
|
|
2194
|
+
].join("\n"),
|
|
2195
|
+
"Convex: preview-only modules"
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
printNextSteps({ cfg, pm, installed: cfg.install });
|
|
2200
|
+
outro(pc3.green(`Done. ${pc3.bold(cfg.projectName)} is ready.`));
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// src/commands/add.ts
|
|
2204
|
+
import fs17 from "fs/promises";
|
|
2205
|
+
import path22 from "path";
|
|
2206
|
+
import { intro as intro2, log as log2, note as note4, outro as outro2, spinner as spinner3 } from "@clack/prompts";
|
|
2207
|
+
import pc4 from "picocolors";
|
|
2208
|
+
function detectStack(pkg) {
|
|
2209
|
+
const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
2210
|
+
if (deps["@clerk/nextjs"]) return "clerk-convex";
|
|
2211
|
+
if (deps["better-auth"]) return "better-auth-drizzle";
|
|
2212
|
+
throw new Error(
|
|
2213
|
+
"Could not detect stack. This project does not appear to be generated by create-crm-starter."
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
function buildConfig(pkg, stack) {
|
|
2217
|
+
const projectName = pkg.name ?? "crm-app";
|
|
2218
|
+
return {
|
|
2219
|
+
projectName,
|
|
2220
|
+
businessName: projectName,
|
|
2221
|
+
industry: "general",
|
|
2222
|
+
primaryColor: "#0ea5e9",
|
|
2223
|
+
accentColor: "#f59e0b",
|
|
2224
|
+
landingPage: true,
|
|
2225
|
+
onlineBooking: true,
|
|
2226
|
+
stack,
|
|
2227
|
+
roles: ["admin", "dispatcher", "technician", "csr"],
|
|
2228
|
+
customers: false,
|
|
2229
|
+
jobs: false,
|
|
2230
|
+
estimatesInvoices: false,
|
|
2231
|
+
calendarDispatch: false,
|
|
2232
|
+
payments: false,
|
|
2233
|
+
comms: [],
|
|
2234
|
+
reviews: false,
|
|
2235
|
+
reporting: false,
|
|
2236
|
+
mobileApp: false,
|
|
2237
|
+
industryExtras: [],
|
|
2238
|
+
seed: false,
|
|
2239
|
+
initGit: false,
|
|
2240
|
+
install: false,
|
|
2241
|
+
deployTarget: "vercel"
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
async function add(args) {
|
|
2245
|
+
intro2(pc4.bgMagenta(pc4.black(` add ${args.module} `)));
|
|
2246
|
+
const projectDir = process.cwd();
|
|
2247
|
+
try {
|
|
2248
|
+
await fs17.access(path22.join(projectDir, "package.json"));
|
|
2249
|
+
} catch {
|
|
2250
|
+
outro2(pc4.red("No package.json in current directory. Run from your project root."));
|
|
2251
|
+
process.exit(1);
|
|
2252
|
+
}
|
|
2253
|
+
const installer = installers[args.module];
|
|
2254
|
+
if (!installer) {
|
|
2255
|
+
const available = Object.keys(installers).filter((k) => !["auth-clerk", "auth-better-auth", "db-convex", "db-drizzle-pg", "brand"].includes(k)).join(", ");
|
|
2256
|
+
outro2(pc4.red(`Unknown module "${args.module}".
|
|
2257
|
+
Available: ${available}`));
|
|
2258
|
+
process.exit(1);
|
|
2259
|
+
}
|
|
2260
|
+
let pkg;
|
|
2261
|
+
let stack;
|
|
2262
|
+
try {
|
|
2263
|
+
pkg = await readPackageJson(projectDir);
|
|
2264
|
+
stack = detectStack(pkg);
|
|
2265
|
+
} catch (err) {
|
|
2266
|
+
outro2(pc4.red(err.message));
|
|
2267
|
+
process.exit(1);
|
|
2268
|
+
}
|
|
2269
|
+
log2.info(`Detected stack: ${pc4.cyan(stack)}`);
|
|
2270
|
+
const cfg = buildConfig(pkg, stack);
|
|
2271
|
+
const s = spinner3();
|
|
2272
|
+
s.start(`Adding ${args.module}`);
|
|
2273
|
+
try {
|
|
2274
|
+
await installer({ projectDir, cfg, pkg });
|
|
2275
|
+
await writePackageJson(projectDir, pkg);
|
|
2276
|
+
s.stop(`Added ${args.module}`);
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
s.stop(`Failed to add ${args.module}`);
|
|
2279
|
+
outro2(pc4.red(`Error: ${err.message}`));
|
|
2280
|
+
process.exit(1);
|
|
2281
|
+
}
|
|
2282
|
+
const lines = [
|
|
2283
|
+
pc4.dim("# Install new dependencies"),
|
|
2284
|
+
pc4.cyan("pnpm install")
|
|
2285
|
+
];
|
|
2286
|
+
if (stack === "better-auth-drizzle" && schemaModules.has(args.module)) {
|
|
2287
|
+
lines.push("");
|
|
2288
|
+
lines.push(pc4.dim("# Apply schema changes"));
|
|
2289
|
+
lines.push(pc4.cyan("pnpm db:push"));
|
|
2290
|
+
} else if (stack === "clerk-convex" && schemaModules.has(args.module)) {
|
|
2291
|
+
lines.push("");
|
|
2292
|
+
lines.push(pc4.dim("# Push schema to Convex"));
|
|
2293
|
+
lines.push(pc4.cyan("pnpm convex:dev"));
|
|
2294
|
+
}
|
|
2295
|
+
note4(lines.join("\n"), "Next steps");
|
|
2296
|
+
outro2(pc4.green("Done."));
|
|
2297
|
+
}
|
|
2298
|
+
var schemaModules = /* @__PURE__ */ new Set(["customers", "jobs"]);
|
|
2299
|
+
|
|
2300
|
+
// src/index.ts
|
|
2301
|
+
var main = defineCommand({
|
|
2302
|
+
meta: {
|
|
2303
|
+
name: "create-crm-starter",
|
|
2304
|
+
// Keep in sync with package.json "version" on each release.
|
|
2305
|
+
version: "0.1.0",
|
|
2306
|
+
description: "Scaffold a home-services CRM (Next.js 15). Run `add <module>` to add a module to an existing project."
|
|
2307
|
+
},
|
|
2308
|
+
args: {
|
|
2309
|
+
projectName: {
|
|
2310
|
+
type: "positional",
|
|
2311
|
+
description: "Directory to create (or `add` to add a module)",
|
|
2312
|
+
required: false
|
|
2313
|
+
},
|
|
2314
|
+
module: {
|
|
2315
|
+
type: "positional",
|
|
2316
|
+
description: "When using `add`: the module name (e.g. payments, calendar)",
|
|
2317
|
+
required: false
|
|
2318
|
+
},
|
|
2319
|
+
config: {
|
|
2320
|
+
type: "string",
|
|
2321
|
+
description: "Path to a JSON file with pre-filled wizard answers (skips interactive prompts)",
|
|
2322
|
+
required: false
|
|
2323
|
+
}
|
|
2324
|
+
},
|
|
2325
|
+
run: ({ args }) => {
|
|
2326
|
+
const positionals = process.argv.slice(2).filter((a) => !a.startsWith("-"));
|
|
2327
|
+
if (positionals[0] === "add") {
|
|
2328
|
+
const moduleName = positionals[1];
|
|
2329
|
+
if (!moduleName) {
|
|
2330
|
+
console.error("Usage: create-crm-starter add <module>");
|
|
2331
|
+
process.exit(1);
|
|
2332
|
+
}
|
|
2333
|
+
return add({ module: moduleName });
|
|
2334
|
+
}
|
|
2335
|
+
return create({
|
|
2336
|
+
projectName: args.projectName ? String(args.projectName) : void 0,
|
|
2337
|
+
configPath: args.config ? String(args.config) : void 0
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
runMain(main);
|