@mars-stack/cli 0.2.0 → 1.0.2
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/dist/index.js +137 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +15 -0
- package/template/src/styles/globals.css +7 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scans each feature directory and updates docs/QUALITY_SCORE.md.
|
|
3
|
+
*
|
|
4
|
+
* For each feature in src/features/, checks:
|
|
5
|
+
* - Has server functions (files in server/)
|
|
6
|
+
* - Has types (*.types.ts, types.ts, or types/)
|
|
7
|
+
* - Has tests (*.test.ts, *.spec.ts, or __tests__/)
|
|
8
|
+
* - Has API routes (corresponding routes in src/app/api/)
|
|
9
|
+
*
|
|
10
|
+
* Grading:
|
|
11
|
+
* - A: All present + tests passing (test files exist with assertions)
|
|
12
|
+
* - B: All four categories present
|
|
13
|
+
* - C: Partial (2-3 categories)
|
|
14
|
+
* - D: Scaffold only (1 category)
|
|
15
|
+
* - F: Missing (0 categories)
|
|
16
|
+
*
|
|
17
|
+
* Usage: tsx scripts/update-quality-score.ts
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
|
|
23
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
24
|
+
const FEATURES_DIR = path.join(ROOT, 'src', 'features');
|
|
25
|
+
const API_DIR = path.join(ROOT, 'src', 'app', 'api');
|
|
26
|
+
const QUALITY_SCORE_PATH = path.join(ROOT, 'docs', 'QUALITY_SCORE.md');
|
|
27
|
+
|
|
28
|
+
type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
|
|
29
|
+
|
|
30
|
+
interface FeatureAssessment {
|
|
31
|
+
name: string;
|
|
32
|
+
hasServer: boolean;
|
|
33
|
+
hasTypes: boolean;
|
|
34
|
+
hasTests: boolean;
|
|
35
|
+
hasApiRoutes: boolean;
|
|
36
|
+
grade: Grade;
|
|
37
|
+
notes: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function collectFilesRecursive(dir: string): string[] {
|
|
41
|
+
const results: string[] = [];
|
|
42
|
+
if (!fs.existsSync(dir)) return results;
|
|
43
|
+
|
|
44
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const full = path.join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
results.push(...collectFilesRecursive(full));
|
|
49
|
+
} else {
|
|
50
|
+
results.push(full);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasServerFunctions(featureDir: string): boolean {
|
|
57
|
+
const serverDir = path.join(featureDir, 'server');
|
|
58
|
+
if (!fs.existsSync(serverDir)) return false;
|
|
59
|
+
|
|
60
|
+
const files = collectFilesRecursive(serverDir);
|
|
61
|
+
return files.some((f) => f.endsWith('.ts') || f.endsWith('.tsx'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasTypeDefinitions(featureDir: string): boolean {
|
|
65
|
+
const files = collectFilesRecursive(featureDir);
|
|
66
|
+
return files.some((f) => {
|
|
67
|
+
const basename = path.basename(f);
|
|
68
|
+
return (
|
|
69
|
+
basename === 'types.ts' ||
|
|
70
|
+
basename === 'types.tsx' ||
|
|
71
|
+
basename.endsWith('.types.ts') ||
|
|
72
|
+
basename.endsWith('.types.tsx')
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasTestFiles(featureDir: string): boolean {
|
|
78
|
+
const files = collectFilesRecursive(featureDir);
|
|
79
|
+
const hasDirectTests = files.some((f) => {
|
|
80
|
+
const basename = path.basename(f);
|
|
81
|
+
return basename.endsWith('.test.ts') || basename.endsWith('.spec.ts') ||
|
|
82
|
+
basename.endsWith('.test.tsx') || basename.endsWith('.spec.tsx');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (hasDirectTests) return true;
|
|
86
|
+
|
|
87
|
+
const testDir = path.join(featureDir, '__tests__');
|
|
88
|
+
if (fs.existsSync(testDir)) {
|
|
89
|
+
const testFiles = collectFilesRecursive(testDir);
|
|
90
|
+
return testFiles.some((f) => f.endsWith('.ts') || f.endsWith('.tsx'));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasApiRoutes(featureName: string): boolean {
|
|
97
|
+
const protectedDir = path.join(API_DIR, 'protected', featureName);
|
|
98
|
+
const publicDir = path.join(API_DIR, featureName);
|
|
99
|
+
|
|
100
|
+
function hasRouteFile(dir: string): boolean {
|
|
101
|
+
if (!fs.existsSync(dir)) return false;
|
|
102
|
+
const files = collectFilesRecursive(dir);
|
|
103
|
+
return files.some((f) => path.basename(f) === 'route.ts' || path.basename(f) === 'route.tsx');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return hasRouteFile(protectedDir) || hasRouteFile(publicDir);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function testsHaveAssertions(featureDir: string): boolean {
|
|
110
|
+
const files = collectFilesRecursive(featureDir);
|
|
111
|
+
const testFiles = files.filter((f) => {
|
|
112
|
+
const basename = path.basename(f);
|
|
113
|
+
return basename.endsWith('.test.ts') || basename.endsWith('.spec.ts') ||
|
|
114
|
+
basename.endsWith('.test.tsx') || basename.endsWith('.spec.tsx');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
for (const testFile of testFiles) {
|
|
118
|
+
const content = fs.readFileSync(testFile, 'utf-8');
|
|
119
|
+
if (content.includes('expect(') || content.includes('assert(') || content.includes('assert.')) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function computeGrade(assessment: FeatureAssessment): Grade {
|
|
128
|
+
const presentCount = [
|
|
129
|
+
assessment.hasServer,
|
|
130
|
+
assessment.hasTypes,
|
|
131
|
+
assessment.hasTests,
|
|
132
|
+
assessment.hasApiRoutes,
|
|
133
|
+
].filter(Boolean).length;
|
|
134
|
+
|
|
135
|
+
if (presentCount === 0) return 'F';
|
|
136
|
+
if (presentCount === 1) return 'D';
|
|
137
|
+
if (presentCount < 4) return 'C';
|
|
138
|
+
|
|
139
|
+
if (assessment.hasTests && testsHaveAssertions(path.join(FEATURES_DIR, assessment.name))) {
|
|
140
|
+
return 'A';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return 'B';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildNotes(assessment: FeatureAssessment): string {
|
|
147
|
+
const present: string[] = [];
|
|
148
|
+
const missing: string[] = [];
|
|
149
|
+
|
|
150
|
+
if (assessment.hasServer) present.push('server'); else missing.push('server');
|
|
151
|
+
if (assessment.hasTypes) present.push('types'); else missing.push('types');
|
|
152
|
+
if (assessment.hasTests) present.push('tests'); else missing.push('tests');
|
|
153
|
+
if (assessment.hasApiRoutes) present.push('api routes'); else missing.push('api routes');
|
|
154
|
+
|
|
155
|
+
const parts: string[] = [];
|
|
156
|
+
if (present.length > 0) parts.push(`Has: ${present.join(', ')}`);
|
|
157
|
+
if (missing.length > 0) parts.push(`Missing: ${missing.join(', ')}`);
|
|
158
|
+
|
|
159
|
+
return parts.join('. ');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function assessFeature(featureName: string): FeatureAssessment {
|
|
163
|
+
const featureDir = path.join(FEATURES_DIR, featureName);
|
|
164
|
+
|
|
165
|
+
const assessment: FeatureAssessment = {
|
|
166
|
+
name: featureName,
|
|
167
|
+
hasServer: hasServerFunctions(featureDir),
|
|
168
|
+
hasTypes: hasTypeDefinitions(featureDir),
|
|
169
|
+
hasTests: hasTestFiles(featureName),
|
|
170
|
+
hasApiRoutes: hasApiRoutes(featureName),
|
|
171
|
+
grade: 'F',
|
|
172
|
+
notes: '',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
assessment.grade = computeGrade(assessment);
|
|
176
|
+
assessment.notes = buildNotes(assessment);
|
|
177
|
+
|
|
178
|
+
return assessment;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function generateMarkdownSection(assessments: FeatureAssessment[]): string {
|
|
182
|
+
const lines: string[] = [
|
|
183
|
+
'## Feature Quality (Auto-Generated)',
|
|
184
|
+
'',
|
|
185
|
+
'> Auto-generated by `yarn update:quality`. Do not edit manually.',
|
|
186
|
+
'',
|
|
187
|
+
'| Feature | Grade | Server | Types | Tests | API Routes | Notes |',
|
|
188
|
+
'|---------|-------|--------|-------|-------|------------|-------|',
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
for (const a of assessments) {
|
|
192
|
+
const check = (v: boolean): string => v ? 'Yes' : 'No';
|
|
193
|
+
lines.push(
|
|
194
|
+
`| ${a.name} | ${a.grade} | ${check(a.hasServer)} | ${check(a.hasTypes)} | ${check(a.hasTests)} | ${check(a.hasApiRoutes)} | ${a.notes} |`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
lines.push('');
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function updateQualityScoreFile(newSection: string): void {
|
|
203
|
+
const docsDir = path.dirname(QUALITY_SCORE_PATH);
|
|
204
|
+
if (!fs.existsSync(docsDir)) {
|
|
205
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!fs.existsSync(QUALITY_SCORE_PATH)) {
|
|
209
|
+
fs.writeFileSync(QUALITY_SCORE_PATH, `# Quality Score\n\n${newSection}`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const content = fs.readFileSync(QUALITY_SCORE_PATH, 'utf-8');
|
|
214
|
+
|
|
215
|
+
const sectionStart = content.indexOf('## Feature Quality (Auto-Generated)');
|
|
216
|
+
if (sectionStart === -1) {
|
|
217
|
+
fs.writeFileSync(QUALITY_SCORE_PATH, content.trimEnd() + '\n\n' + newSection);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const nextSectionMatch = content.slice(sectionStart + 1).match(/\n## [^#]/);
|
|
222
|
+
const sectionEnd = nextSectionMatch
|
|
223
|
+
? sectionStart + 1 + nextSectionMatch.index!
|
|
224
|
+
: content.length;
|
|
225
|
+
|
|
226
|
+
const updated = content.slice(0, sectionStart) + newSection + content.slice(sectionEnd);
|
|
227
|
+
fs.writeFileSync(QUALITY_SCORE_PATH, updated);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function main(): void {
|
|
231
|
+
console.log('Scanning features and updating quality scores...\n');
|
|
232
|
+
|
|
233
|
+
if (!fs.existsSync(FEATURES_DIR)) {
|
|
234
|
+
console.log('No features directory found at src/features/. Nothing to scan.');
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const featureNames = fs
|
|
239
|
+
.readdirSync(FEATURES_DIR, { withFileTypes: true })
|
|
240
|
+
.filter((e) => e.isDirectory())
|
|
241
|
+
.map((e) => e.name)
|
|
242
|
+
.sort();
|
|
243
|
+
|
|
244
|
+
if (featureNames.length === 0) {
|
|
245
|
+
console.log('No features found in src/features/.');
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const assessments = featureNames.map(assessFeature);
|
|
250
|
+
|
|
251
|
+
console.log('Feature Assessment Results:\n');
|
|
252
|
+
for (const a of assessments) {
|
|
253
|
+
const icon = a.grade === 'A' ? '+' : a.grade === 'F' ? '!' : '-';
|
|
254
|
+
console.log(` [${icon}] ${a.name}: ${a.grade} — ${a.notes}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const newSection = generateMarkdownSection(assessments);
|
|
258
|
+
updateQualityScoreFile(newSection);
|
|
259
|
+
|
|
260
|
+
console.log(`\nUpdated ${path.relative(ROOT, QUALITY_SCORE_PATH)}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
main();
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural tests that validate architecture invariants.
|
|
3
|
+
* These tests scan the codebase and assert that architectural rules are followed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
|
|
10
|
+
const SRC_DIR = path.resolve(__dirname, '..');
|
|
11
|
+
|
|
12
|
+
function getAllFiles(dir: string, extensions: string[]): string[] {
|
|
13
|
+
const results: string[] = [];
|
|
14
|
+
if (!fs.existsSync(dir)) return results;
|
|
15
|
+
|
|
16
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const fullPath = path.join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
if (entry.name === 'node_modules' || entry.name === '.next' || entry.name === 'dist') continue;
|
|
21
|
+
results.push(...getAllFiles(fullPath, extensions));
|
|
22
|
+
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
23
|
+
results.push(fullPath);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('Architecture: Dependency Direction', () => {
|
|
30
|
+
it('features do not import from other features', () => {
|
|
31
|
+
const featuresDir = path.join(SRC_DIR, 'features');
|
|
32
|
+
if (!fs.existsSync(featuresDir)) return;
|
|
33
|
+
|
|
34
|
+
const featureDirs = fs
|
|
35
|
+
.readdirSync(featuresDir, { withFileTypes: true })
|
|
36
|
+
.filter((d) => d.isDirectory())
|
|
37
|
+
.map((d) => d.name);
|
|
38
|
+
|
|
39
|
+
const violations: string[] = [];
|
|
40
|
+
|
|
41
|
+
for (const feature of featureDirs) {
|
|
42
|
+
const featureDir = path.join(featuresDir, feature);
|
|
43
|
+
const files = getAllFiles(featureDir, ['.ts', '.tsx']);
|
|
44
|
+
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
47
|
+
const importMatches = content.matchAll(
|
|
48
|
+
/(?:import|from)\s+['"](?:@\/|\.\.\/.*\/)features\/([^/'"\s]+)/g
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
for (const match of importMatches) {
|
|
52
|
+
const importedFeature = match[1];
|
|
53
|
+
if (importedFeature !== feature) {
|
|
54
|
+
const relPath = path.relative(SRC_DIR, file);
|
|
55
|
+
violations.push(
|
|
56
|
+
`${relPath} imports from features/${importedFeature} (cross-feature import)`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(violations).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('Architecture: API Auth Coverage', () => {
|
|
68
|
+
it('all routes in api/protected/ use auth middleware', () => {
|
|
69
|
+
const protectedDir = path.join(SRC_DIR, 'app', 'api', 'protected');
|
|
70
|
+
if (!fs.existsSync(protectedDir)) return;
|
|
71
|
+
|
|
72
|
+
const routeFiles = getAllFiles(protectedDir, ['.ts', '.tsx']).filter(
|
|
73
|
+
(f) => path.basename(f).startsWith('route.')
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const unprotected: string[] = [];
|
|
77
|
+
|
|
78
|
+
for (const file of routeFiles) {
|
|
79
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
80
|
+
const hasAuth =
|
|
81
|
+
content.includes('withAuth') ||
|
|
82
|
+
content.includes('withRole') ||
|
|
83
|
+
content.includes('withOwnership');
|
|
84
|
+
|
|
85
|
+
if (!hasAuth) {
|
|
86
|
+
unprotected.push(path.relative(SRC_DIR, file));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
expect(unprotected).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('Architecture: Server-Only Boundary', () => {
|
|
95
|
+
it('files in server/ directories import "server-only"', () => {
|
|
96
|
+
const files = getAllFiles(SRC_DIR, ['.ts', '.tsx']).filter(
|
|
97
|
+
(f) =>
|
|
98
|
+
f.includes('/server/') &&
|
|
99
|
+
!f.includes('.test.') &&
|
|
100
|
+
!f.includes('node_modules')
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const missing: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
107
|
+
if (!content.includes("import 'server-only'") && !content.includes('import "server-only"')) {
|
|
108
|
+
missing.push(path.relative(SRC_DIR, file));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
expect(missing).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCSRF } from '@mars-stack/core/auth/hooks';
|
|
4
|
+
import { formSchemas } from '@mars-stack/core/auth/validation';
|
|
5
|
+
import { useZodForm } from '@mars-stack/ui/hooks';
|
|
6
|
+
import { Button, Input, Text } from '@mars-stack/ui';
|
|
7
|
+
import Link from 'next/link';
|
|
8
|
+
import { routes } from '@/config/routes';
|
|
9
|
+
import { useState } from 'react';
|
|
10
|
+
|
|
11
|
+
export default function ForgottenPassword() {
|
|
12
|
+
const { getCSRFHeaders } = useCSRF();
|
|
13
|
+
const [serverError, setServerError] = useState('');
|
|
14
|
+
const [success, setSuccess] = useState(false);
|
|
15
|
+
|
|
16
|
+
const form = useZodForm({
|
|
17
|
+
schema: formSchemas.forgotPassword,
|
|
18
|
+
initialValues: { email: '' },
|
|
19
|
+
mode: 'onBlur',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const handleSubmit = form.handleSubmit(async (values) => {
|
|
23
|
+
setServerError('');
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch('/api/auth/forgot', {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
|
|
28
|
+
body: JSON.stringify(values),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const data = await response.json();
|
|
33
|
+
setServerError(data.error || 'Failed to send reset email');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setSuccess(true);
|
|
38
|
+
} catch {
|
|
39
|
+
setServerError('Network error. Please try again.');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (success) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="text-center">
|
|
46
|
+
<Text.H3 as="h2">Check your email</Text.H3>
|
|
47
|
+
<Text.Paragraph>
|
|
48
|
+
If an account exists for that email, we've sent password reset instructions.
|
|
49
|
+
</Text.Paragraph>
|
|
50
|
+
<Link href={routes.signIn} className="mt-6 inline-block text-sm text-text-link hover:text-text-link-hover hover:underline">
|
|
51
|
+
Back to sign in
|
|
52
|
+
</Link>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<div>
|
|
60
|
+
<Text.H3 as="h2">Reset your password</Text.H3>
|
|
61
|
+
<Text.Paragraph className="text-sm">
|
|
62
|
+
Enter your email address and we'll send you a link to reset your password.
|
|
63
|
+
</Text.Paragraph>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
67
|
+
{serverError && (
|
|
68
|
+
<div className="rounded-md bg-error-muted p-3 text-sm text-text-error">{serverError}</div>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
<Input
|
|
72
|
+
{...form.getFieldProps('email')}
|
|
73
|
+
label="Email Address"
|
|
74
|
+
type="email"
|
|
75
|
+
placeholder="you@example.com"
|
|
76
|
+
fullWidth
|
|
77
|
+
required
|
|
78
|
+
/>
|
|
79
|
+
|
|
80
|
+
<Button type="submit" disabled={form.isSubmitting || !form.isValid} fullWidth loading={form.isSubmitting}>
|
|
81
|
+
{form.isSubmitting ? 'Sending...' : 'Send reset link'}
|
|
82
|
+
</Button>
|
|
83
|
+
</form>
|
|
84
|
+
|
|
85
|
+
<div className="mt-4 text-center">
|
|
86
|
+
<Link href={routes.signIn} className="text-sm text-text-link hover:text-text-link-hover hover:underline">
|
|
87
|
+
Back to sign in
|
|
88
|
+
</Link>
|
|
89
|
+
</div>
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Card } from '@mars-stack/ui';
|
|
2
|
+
|
|
3
|
+
export default function AuthLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="flex min-h-screen items-start justify-center bg-surface-background px-4 py-8 sm:px-6 lg:px-8">
|
|
6
|
+
<Card className="w-full max-w-md space-y-8" padding="lg">
|
|
7
|
+
{children}
|
|
8
|
+
</Card>
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useAuth } from '@/features/auth/context/AuthContext';
|
|
4
|
+
import { useCSRF, usePasswordStrength } from '@mars-stack/core/auth/hooks';
|
|
5
|
+
import { formSchemas, type SignupFormData } from '@mars-stack/core/auth/validation';
|
|
6
|
+
import { routes } from '@/config/routes';
|
|
7
|
+
import { useZodForm } from '@mars-stack/ui/hooks';
|
|
8
|
+
import { Button, Checkbox, Input, Spinner, Text, PasswordStrengthIndicator } from '@mars-stack/ui';
|
|
9
|
+
import Link from 'next/link';
|
|
10
|
+
import { useRouter } from 'next/navigation';
|
|
11
|
+
import { Suspense, useEffect, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
function SignUpForm() {
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
16
|
+
const { getCSRFHeaders, getCSRFFormData } = useCSRF();
|
|
17
|
+
const [serverError, setServerError] = useState('');
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!isLoading && isAuthenticated) router.push(routes.dashboard);
|
|
21
|
+
}, [isAuthenticated, isLoading, router]);
|
|
22
|
+
|
|
23
|
+
const form = useZodForm({
|
|
24
|
+
schema: formSchemas.signup,
|
|
25
|
+
initialValues: {
|
|
26
|
+
name: '',
|
|
27
|
+
email: '',
|
|
28
|
+
password: '',
|
|
29
|
+
confirmPassword: '',
|
|
30
|
+
termsAccepted: false,
|
|
31
|
+
marketingOptIn: false,
|
|
32
|
+
},
|
|
33
|
+
mode: 'onBlur',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const passwordStrength = usePasswordStrength(form.values.password);
|
|
37
|
+
|
|
38
|
+
const handleSubmit = form.handleSubmit(async (values: SignupFormData) => {
|
|
39
|
+
setServerError('');
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch('/api/auth/signup', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
|
|
44
|
+
body: JSON.stringify({ ...values, ...getCSRFFormData() }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const data = await response.json();
|
|
48
|
+
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
setServerError(data.error || 'Failed to create account');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
sessionStorage.setItem('verify-email', values.email);
|
|
55
|
+
router.push('/verify');
|
|
56
|
+
} catch {
|
|
57
|
+
setServerError('Network error. Please try again.');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (isLoading) return <Spinner size="md" />;
|
|
62
|
+
if (isAuthenticated) return <Text.Paragraph>Redirecting...</Text.Paragraph>;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<div>
|
|
67
|
+
<Text.H3 as="h2">Create your account</Text.H3>
|
|
68
|
+
<Text.Paragraph className="text-sm">
|
|
69
|
+
Already have an account?{' '}
|
|
70
|
+
<Link href={routes.signIn} className="text-text-link hover:text-text-link-hover hover:underline">
|
|
71
|
+
Sign in
|
|
72
|
+
</Link>
|
|
73
|
+
</Text.Paragraph>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
77
|
+
{serverError && (
|
|
78
|
+
<div className="rounded-md bg-error-muted p-3 text-sm text-text-error">{serverError}</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<div className="space-y-4">
|
|
82
|
+
<Input
|
|
83
|
+
{...form.getFieldProps('name')}
|
|
84
|
+
label="Full Name"
|
|
85
|
+
type="text"
|
|
86
|
+
placeholder="John Doe"
|
|
87
|
+
fullWidth
|
|
88
|
+
required
|
|
89
|
+
/>
|
|
90
|
+
<Input
|
|
91
|
+
{...form.getFieldProps('email')}
|
|
92
|
+
label="Email Address"
|
|
93
|
+
type="email"
|
|
94
|
+
placeholder="you@example.com"
|
|
95
|
+
fullWidth
|
|
96
|
+
required
|
|
97
|
+
/>
|
|
98
|
+
<Input
|
|
99
|
+
{...form.getFieldProps('password')}
|
|
100
|
+
label="Password"
|
|
101
|
+
type="password"
|
|
102
|
+
placeholder="Create a strong password"
|
|
103
|
+
showPasswordToggle
|
|
104
|
+
fullWidth
|
|
105
|
+
required
|
|
106
|
+
/>
|
|
107
|
+
{form.values.password && (
|
|
108
|
+
<PasswordStrengthIndicator
|
|
109
|
+
strength={passwordStrength.strength}
|
|
110
|
+
requirements={passwordStrength.requirements}
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
<Input
|
|
114
|
+
{...form.getFieldProps('confirmPassword')}
|
|
115
|
+
label="Confirm Password"
|
|
116
|
+
type="password"
|
|
117
|
+
placeholder="Re-enter your password"
|
|
118
|
+
fullWidth
|
|
119
|
+
required
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
<div className="space-y-3">
|
|
123
|
+
<Checkbox
|
|
124
|
+
checked={form.values.termsAccepted}
|
|
125
|
+
onChange={(e) => form.setValue('termsAccepted', (e.target as HTMLInputElement).checked)}
|
|
126
|
+
required
|
|
127
|
+
label={
|
|
128
|
+
<span>
|
|
129
|
+
I agree to the{' '}
|
|
130
|
+
<Link href="/terms" className="text-text-link hover:underline">
|
|
131
|
+
Terms of Service
|
|
132
|
+
</Link>{' '}
|
|
133
|
+
and{' '}
|
|
134
|
+
<Link href="/privacy" className="text-text-link hover:underline">
|
|
135
|
+
Privacy Policy
|
|
136
|
+
</Link>
|
|
137
|
+
</span>
|
|
138
|
+
}
|
|
139
|
+
/>
|
|
140
|
+
<Checkbox
|
|
141
|
+
checked={form.values.marketingOptIn}
|
|
142
|
+
onChange={(e) => form.setValue('marketingOptIn', (e.target as HTMLInputElement).checked)}
|
|
143
|
+
label="I would like to receive product updates and marketing emails"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<Button type="submit" disabled={form.isSubmitting || !form.isValid} fullWidth loading={form.isSubmitting}>
|
|
149
|
+
{form.isSubmitting ? 'Creating account...' : 'Create account'}
|
|
150
|
+
</Button>
|
|
151
|
+
</form>
|
|
152
|
+
</>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export default function Register() {
|
|
157
|
+
return (
|
|
158
|
+
<Suspense fallback={<Spinner size="md" />}>
|
|
159
|
+
<SignUpForm />
|
|
160
|
+
</Suspense>
|
|
161
|
+
);
|
|
162
|
+
}
|