@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,17 @@
|
|
|
1
|
+
|
|
2
|
+
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
|
3
|
+
/* eslint-disable */
|
|
4
|
+
// biome-ignore-all lint: generated file
|
|
5
|
+
// @ts-nocheck
|
|
6
|
+
/*
|
|
7
|
+
* This is a barrel export file for all models and their related types.
|
|
8
|
+
*
|
|
9
|
+
* 🟢 You can import this file directly.
|
|
10
|
+
*/
|
|
11
|
+
export type * from './models/User'
|
|
12
|
+
export type * from './models/Account'
|
|
13
|
+
export type * from './models/Session'
|
|
14
|
+
export type * from './models/VerificationToken'
|
|
15
|
+
export type * from './models/File'
|
|
16
|
+
export type * from './models/Subscription'
|
|
17
|
+
export type * from './commonInputTypes'
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
model User {
|
|
2
|
+
id String @id @default(cuid())
|
|
3
|
+
email String @unique
|
|
4
|
+
name String?
|
|
5
|
+
password String?
|
|
6
|
+
role String @default("user")
|
|
7
|
+
emailVerified DateTime?
|
|
8
|
+
image String?
|
|
9
|
+
createdAt DateTime @default(now())
|
|
10
|
+
updatedAt DateTime @updatedAt
|
|
11
|
+
|
|
12
|
+
failedLoginAttempts Int @default(0)
|
|
13
|
+
lastFailedLogin DateTime?
|
|
14
|
+
lockedUntil DateTime?
|
|
15
|
+
|
|
16
|
+
termsAcceptedAt DateTime?
|
|
17
|
+
privacyAcceptedAt DateTime?
|
|
18
|
+
marketingOptIn Boolean @default(false)
|
|
19
|
+
marketingOptInAt DateTime?
|
|
20
|
+
|
|
21
|
+
accounts Account[]
|
|
22
|
+
sessions Session[]
|
|
23
|
+
files File[]
|
|
24
|
+
subscription Subscription?
|
|
25
|
+
|
|
26
|
+
@@index([email])
|
|
27
|
+
@@index([role])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
model Account {
|
|
31
|
+
id String @id @default(cuid())
|
|
32
|
+
userId String
|
|
33
|
+
provider String
|
|
34
|
+
providerAccountId String
|
|
35
|
+
refreshToken String?
|
|
36
|
+
accessToken String?
|
|
37
|
+
expiresAt Int?
|
|
38
|
+
tokenType String?
|
|
39
|
+
scope String?
|
|
40
|
+
|
|
41
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
42
|
+
|
|
43
|
+
@@unique([provider, providerAccountId])
|
|
44
|
+
@@index([userId])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
model Session {
|
|
48
|
+
id String @id @default(cuid())
|
|
49
|
+
userId String
|
|
50
|
+
token String @unique
|
|
51
|
+
expiresAt DateTime
|
|
52
|
+
createdAt DateTime @default(now())
|
|
53
|
+
ipAddress String?
|
|
54
|
+
userAgent String?
|
|
55
|
+
|
|
56
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
57
|
+
|
|
58
|
+
@@index([userId])
|
|
59
|
+
@@index([token])
|
|
60
|
+
@@index([expiresAt])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
model VerificationToken {
|
|
64
|
+
identifier String
|
|
65
|
+
token String @unique
|
|
66
|
+
expires DateTime
|
|
67
|
+
|
|
68
|
+
@@unique([identifier, token])
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
model File {
|
|
2
|
+
id String @id @default(cuid())
|
|
3
|
+
userId String
|
|
4
|
+
filename String
|
|
5
|
+
url String
|
|
6
|
+
contentType String
|
|
7
|
+
size Int
|
|
8
|
+
access String @default("private")
|
|
9
|
+
createdAt DateTime @default(now())
|
|
10
|
+
updatedAt DateTime @updatedAt
|
|
11
|
+
|
|
12
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
13
|
+
|
|
14
|
+
@@index([userId])
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
model Subscription {
|
|
2
|
+
id String @id @default(cuid())
|
|
3
|
+
userId String @unique
|
|
4
|
+
stripeCustomerId String @unique
|
|
5
|
+
stripePriceId String?
|
|
6
|
+
stripeSubscriptionId String? @unique
|
|
7
|
+
status String @default("inactive")
|
|
8
|
+
currentPeriodEnd DateTime?
|
|
9
|
+
cancelAtPeriodEnd Boolean @default(false)
|
|
10
|
+
createdAt DateTime @default(now())
|
|
11
|
+
updatedAt DateTime @updatedAt
|
|
12
|
+
|
|
13
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
14
|
+
|
|
15
|
+
@@index([stripeCustomerId])
|
|
16
|
+
@@index([stripeSubscriptionId])
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { defineConfig } from 'prisma/config';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
schema: 'prisma/schema',
|
|
6
|
+
migrations: {
|
|
7
|
+
path: 'prisma/migrations',
|
|
8
|
+
seed: 'tsx scripts/seed.ts',
|
|
9
|
+
},
|
|
10
|
+
datasource: {
|
|
11
|
+
url: process.env.DATABASE_URL ?? 'postgresql://',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scans imports and validates architectural invariants.
|
|
3
|
+
*
|
|
4
|
+
* Checks:
|
|
5
|
+
* 1. No cross-feature imports (features must not import from other features)
|
|
6
|
+
* 2. Server modules have `server-only` import
|
|
7
|
+
* 3. Protected API routes use auth middleware
|
|
8
|
+
* 4. Design tokens used instead of raw Tailwind colors in .tsx files
|
|
9
|
+
*
|
|
10
|
+
* Exit code 0 if clean, 1 if violations found.
|
|
11
|
+
*
|
|
12
|
+
* Usage: tsx scripts/check-architecture.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
|
|
18
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
19
|
+
const FEATURES_DIR = path.join(ROOT, 'src', 'features');
|
|
20
|
+
const API_DIR = path.join(ROOT, 'src', 'app', 'api');
|
|
21
|
+
|
|
22
|
+
interface Violation {
|
|
23
|
+
file: string;
|
|
24
|
+
rule: string;
|
|
25
|
+
message: string;
|
|
26
|
+
remediation: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function collectFiles(dir: string, ext: string[]): string[] {
|
|
30
|
+
const results: string[] = [];
|
|
31
|
+
if (!fs.existsSync(dir)) return results;
|
|
32
|
+
|
|
33
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const full = path.join(dir, entry.name);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
results.push(...collectFiles(full, ext));
|
|
38
|
+
} else if (ext.some((e) => entry.name.endsWith(e))) {
|
|
39
|
+
results.push(full);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getFeatureName(filePath: string): string | null {
|
|
46
|
+
const rel = path.relative(FEATURES_DIR, filePath);
|
|
47
|
+
if (rel.startsWith('..')) return null;
|
|
48
|
+
return rel.split(path.sep)[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkCrossFeatureImports(violations: Violation[]): void {
|
|
52
|
+
if (!fs.existsSync(FEATURES_DIR)) return;
|
|
53
|
+
|
|
54
|
+
const featureFiles = collectFiles(FEATURES_DIR, ['.ts', '.tsx']);
|
|
55
|
+
|
|
56
|
+
for (const file of featureFiles) {
|
|
57
|
+
const featureName = getFeatureName(file);
|
|
58
|
+
if (!featureName) continue;
|
|
59
|
+
|
|
60
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
61
|
+
const _importRegex = /(?:import|from)\s+['"](@\/features\/|\.\.\/\.\.\/|\.\.\.\/)/g;
|
|
62
|
+
const lines = content.split('\n');
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
const line = lines[i];
|
|
66
|
+
|
|
67
|
+
const featureImportMatch = line.match(
|
|
68
|
+
/(?:import|from)\s+['"]@\/features\/(\w+)/,
|
|
69
|
+
);
|
|
70
|
+
if (featureImportMatch && featureImportMatch[1] !== featureName) {
|
|
71
|
+
violations.push({
|
|
72
|
+
file: path.relative(ROOT, file),
|
|
73
|
+
rule: 'no-cross-feature-imports',
|
|
74
|
+
message: `Feature "${featureName}" imports from feature "${featureImportMatch[1]}" (line ${i + 1})`,
|
|
75
|
+
remediation: `Move shared logic to src/lib/ or @mars-stack/* packages. Features must only import from @/lib/, @mars-stack/*, and @/config/.`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const relativeMatch = line.match(
|
|
80
|
+
/(?:import|from)\s+['"](\.\.\/(?:\.\.\/)*)/,
|
|
81
|
+
);
|
|
82
|
+
if (relativeMatch) {
|
|
83
|
+
const resolved = path.resolve(path.dirname(file), relativeMatch[1]);
|
|
84
|
+
const resolvedFeature = getFeatureName(resolved);
|
|
85
|
+
if (
|
|
86
|
+
resolvedFeature &&
|
|
87
|
+
resolvedFeature !== featureName &&
|
|
88
|
+
resolved.startsWith(FEATURES_DIR)
|
|
89
|
+
) {
|
|
90
|
+
violations.push({
|
|
91
|
+
file: path.relative(ROOT, file),
|
|
92
|
+
rule: 'no-cross-feature-imports',
|
|
93
|
+
message: `Feature "${featureName}" has relative import reaching into feature "${resolvedFeature}" (line ${i + 1})`,
|
|
94
|
+
remediation: `Refactor shared logic into src/lib/ instead of importing across features.`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkServerOnlyImport(violations: Violation[]): void {
|
|
103
|
+
const serverDirs: string[] = [];
|
|
104
|
+
|
|
105
|
+
function findServerDirs(dir: string): void {
|
|
106
|
+
if (!fs.existsSync(dir)) return;
|
|
107
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
const full = path.join(dir, entry.name);
|
|
111
|
+
if (entry.name === 'server') {
|
|
112
|
+
serverDirs.push(full);
|
|
113
|
+
}
|
|
114
|
+
findServerDirs(full);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
findServerDirs(path.join(ROOT, 'src'));
|
|
120
|
+
|
|
121
|
+
for (const serverDir of serverDirs) {
|
|
122
|
+
const files = collectFiles(serverDir, ['.ts', '.tsx']);
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
125
|
+
if (!content.includes("import 'server-only'") && !content.includes('import "server-only"')) {
|
|
126
|
+
violations.push({
|
|
127
|
+
file: path.relative(ROOT, file),
|
|
128
|
+
rule: 'server-only-import',
|
|
129
|
+
message: `Server module missing "server-only" import`,
|
|
130
|
+
remediation: `Add \`import 'server-only'\` as the first import in this file to prevent client-side bundling.`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function checkProtectedRouteAuth(violations: Violation[]): void {
|
|
138
|
+
const protectedDir = path.join(API_DIR, 'protected');
|
|
139
|
+
if (!fs.existsSync(protectedDir)) return;
|
|
140
|
+
|
|
141
|
+
const routeFiles = collectFiles(protectedDir, ['.ts', '.tsx']);
|
|
142
|
+
const authPatterns = ['withAuth', 'withAuthNoParams', 'withRole', 'withOwnership'];
|
|
143
|
+
|
|
144
|
+
for (const file of routeFiles) {
|
|
145
|
+
if (!file.endsWith('route.ts') && !file.endsWith('route.tsx')) continue;
|
|
146
|
+
|
|
147
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
148
|
+
const hasAuth = authPatterns.some((pattern) => content.includes(pattern));
|
|
149
|
+
|
|
150
|
+
if (!hasAuth) {
|
|
151
|
+
violations.push({
|
|
152
|
+
file: path.relative(ROOT, file),
|
|
153
|
+
rule: 'protected-route-auth',
|
|
154
|
+
message: `Protected API route does not use auth middleware`,
|
|
155
|
+
remediation: `Wrap the handler with withAuth, withRole, or withOwnership from @/lib/mars.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const RAW_COLOR_PATTERN = /(?:bg|text|border|ring|outline|shadow|accent|fill|stroke)-(?:red|blue|green|yellow|orange|purple|pink|indigo|violet|teal|cyan|emerald|lime|amber|fuchsia|rose|sky|slate|gray|zinc|neutral|stone)-\d{2,3}/g;
|
|
162
|
+
|
|
163
|
+
function checkDesignTokenUsage(violations: Violation[]): void {
|
|
164
|
+
const tsxFiles = collectFiles(path.join(ROOT, 'src'), ['.tsx']);
|
|
165
|
+
|
|
166
|
+
for (const file of tsxFiles) {
|
|
167
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
168
|
+
const lines = content.split('\n');
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
const matches = lines[i].match(RAW_COLOR_PATTERN);
|
|
172
|
+
if (matches) {
|
|
173
|
+
for (const match of matches) {
|
|
174
|
+
violations.push({
|
|
175
|
+
file: path.relative(ROOT, file),
|
|
176
|
+
rule: 'design-tokens-only',
|
|
177
|
+
message: `Raw Tailwind color "${match}" found (line ${i + 1})`,
|
|
178
|
+
remediation: `Replace with a semantic design token (e.g., bg-surface-primary, text-content-primary). See @mars-stack/ui/styles for available tokens.`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function main(): void {
|
|
187
|
+
const violations: Violation[] = [];
|
|
188
|
+
|
|
189
|
+
console.log('Checking architectural invariants...\n');
|
|
190
|
+
|
|
191
|
+
checkCrossFeatureImports(violations);
|
|
192
|
+
checkServerOnlyImport(violations);
|
|
193
|
+
checkProtectedRouteAuth(violations);
|
|
194
|
+
checkDesignTokenUsage(violations);
|
|
195
|
+
|
|
196
|
+
if (violations.length === 0) {
|
|
197
|
+
console.log('All checks passed. No architectural violations found.');
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const grouped = new Map<string, Violation[]>();
|
|
202
|
+
for (const v of violations) {
|
|
203
|
+
const existing = grouped.get(v.rule) ?? [];
|
|
204
|
+
existing.push(v);
|
|
205
|
+
grouped.set(v.rule, existing);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const [rule, ruleViolations] of grouped) {
|
|
209
|
+
console.log(`\n--- ${rule} (${ruleViolations.length} violation${ruleViolations.length > 1 ? 's' : ''}) ---\n`);
|
|
210
|
+
for (const v of ruleViolations) {
|
|
211
|
+
console.log(` ${v.file}`);
|
|
212
|
+
console.log(` ${v.message}`);
|
|
213
|
+
console.log(` Fix: ${v.remediation}\n`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
console.log(`\nTotal: ${violations.length} violation${violations.length > 1 ? 's' : ''} found.`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
main();
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compares generated docs against actual codebase state.
|
|
3
|
+
*
|
|
4
|
+
* Checks:
|
|
5
|
+
* 1. docs/generated/api-routes.md matches actual routes in src/app/api/
|
|
6
|
+
* 2. docs/generated/db-schema.md matches Prisma schema files
|
|
7
|
+
*
|
|
8
|
+
* Reports stale entries (in docs but not in code) and missing entries
|
|
9
|
+
* (in code but not in docs).
|
|
10
|
+
*
|
|
11
|
+
* Exit code 0 if fresh, 1 if stale.
|
|
12
|
+
*
|
|
13
|
+
* Usage: tsx scripts/check-doc-freshness.ts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
20
|
+
const GENERATED_DIR = path.join(ROOT, 'docs', 'generated');
|
|
21
|
+
const API_DIR = path.join(ROOT, 'src', 'app', 'api');
|
|
22
|
+
const SCHEMA_DIR = path.join(ROOT, 'prisma', 'schema');
|
|
23
|
+
|
|
24
|
+
interface FreshnessIssue {
|
|
25
|
+
category: string;
|
|
26
|
+
type: 'stale' | 'missing';
|
|
27
|
+
entry: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function discoverApiRoutes(): Set<string> {
|
|
31
|
+
const routes = new Set<string>();
|
|
32
|
+
if (!fs.existsSync(API_DIR)) return routes;
|
|
33
|
+
|
|
34
|
+
function scan(dir: string, prefix: string): void {
|
|
35
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
scan(path.join(dir, entry.name), `${prefix}/${entry.name}`);
|
|
39
|
+
} else if (entry.name === 'route.ts' || entry.name === 'route.tsx') {
|
|
40
|
+
routes.add(`/api${prefix}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
scan(API_DIR, '');
|
|
46
|
+
return routes;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseDocumentedRoutes(): Set<string> {
|
|
50
|
+
const documented = new Set<string>();
|
|
51
|
+
const docPath = path.join(GENERATED_DIR, 'api-routes.md');
|
|
52
|
+
if (!fs.existsSync(docPath)) return documented;
|
|
53
|
+
|
|
54
|
+
const content = fs.readFileSync(docPath, 'utf-8');
|
|
55
|
+
const routeRegex = /\|\s*`(\/api\/[^`]+)`/g;
|
|
56
|
+
let match: RegExpExecArray | null;
|
|
57
|
+
|
|
58
|
+
while ((match = routeRegex.exec(content)) !== null) {
|
|
59
|
+
documented.add(match[1]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return documented;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkApiRoutes(issues: FreshnessIssue[]): void {
|
|
66
|
+
const actual = discoverApiRoutes();
|
|
67
|
+
const documented = parseDocumentedRoutes();
|
|
68
|
+
|
|
69
|
+
if (documented.size === 0 && actual.size > 0) {
|
|
70
|
+
issues.push({
|
|
71
|
+
category: 'api-routes',
|
|
72
|
+
type: 'missing',
|
|
73
|
+
entry: 'docs/generated/api-routes.md does not exist or is empty',
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const route of documented) {
|
|
79
|
+
if (!actual.has(route)) {
|
|
80
|
+
issues.push({
|
|
81
|
+
category: 'api-routes',
|
|
82
|
+
type: 'stale',
|
|
83
|
+
entry: `${route} is documented but no longer exists in codebase`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const route of actual) {
|
|
89
|
+
if (!documented.has(route)) {
|
|
90
|
+
issues.push({
|
|
91
|
+
category: 'api-routes',
|
|
92
|
+
type: 'missing',
|
|
93
|
+
entry: `${route} exists in codebase but is not documented`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function discoverPrismaModels(): Map<string, Set<string>> {
|
|
100
|
+
const models = new Map<string, Set<string>>();
|
|
101
|
+
if (!fs.existsSync(SCHEMA_DIR)) return models;
|
|
102
|
+
|
|
103
|
+
const schemaFiles = fs.readdirSync(SCHEMA_DIR).filter((f) => f.endsWith('.prisma'));
|
|
104
|
+
|
|
105
|
+
for (const file of schemaFiles) {
|
|
106
|
+
const content = fs.readFileSync(path.join(SCHEMA_DIR, file), 'utf-8');
|
|
107
|
+
const modelRegex = /model\s+(\w+)\s*\{/g;
|
|
108
|
+
let match: RegExpExecArray | null;
|
|
109
|
+
const fileModels = new Set<string>();
|
|
110
|
+
|
|
111
|
+
while ((match = modelRegex.exec(content)) !== null) {
|
|
112
|
+
fileModels.add(match[1]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (fileModels.size > 0) {
|
|
116
|
+
models.set(file, fileModels);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return models;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseDocumentedModels(): Map<string, Set<string>> {
|
|
124
|
+
const documented = new Map<string, Set<string>>();
|
|
125
|
+
const docPath = path.join(GENERATED_DIR, 'db-schema.md');
|
|
126
|
+
if (!fs.existsSync(docPath)) return documented;
|
|
127
|
+
|
|
128
|
+
const content = fs.readFileSync(docPath, 'utf-8');
|
|
129
|
+
let currentFile: string | null = null;
|
|
130
|
+
|
|
131
|
+
for (const line of content.split('\n')) {
|
|
132
|
+
const fileMatch = line.match(/^## (\S+\.prisma)/);
|
|
133
|
+
if (fileMatch) {
|
|
134
|
+
currentFile = fileMatch[1];
|
|
135
|
+
documented.set(currentFile, new Set());
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const modelMatch = line.match(/^### (\w+)/);
|
|
140
|
+
if (modelMatch && currentFile) {
|
|
141
|
+
documented.get(currentFile)?.add(modelMatch[1]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return documented;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkDbSchema(issues: FreshnessIssue[]): void {
|
|
149
|
+
const actual = discoverPrismaModels();
|
|
150
|
+
const documented = parseDocumentedModels();
|
|
151
|
+
|
|
152
|
+
if (documented.size === 0 && actual.size > 0) {
|
|
153
|
+
issues.push({
|
|
154
|
+
category: 'db-schema',
|
|
155
|
+
type: 'missing',
|
|
156
|
+
entry: 'docs/generated/db-schema.md does not exist or is empty',
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const allFiles = new Set([...actual.keys(), ...documented.keys()]);
|
|
162
|
+
|
|
163
|
+
for (const file of allFiles) {
|
|
164
|
+
const actualModels = actual.get(file) ?? new Set<string>();
|
|
165
|
+
const docModels = documented.get(file) ?? new Set<string>();
|
|
166
|
+
|
|
167
|
+
if (!actual.has(file) && documented.has(file)) {
|
|
168
|
+
issues.push({
|
|
169
|
+
category: 'db-schema',
|
|
170
|
+
type: 'stale',
|
|
171
|
+
entry: `Schema file "${file}" is documented but no longer exists`,
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (actual.has(file) && !documented.has(file)) {
|
|
177
|
+
issues.push({
|
|
178
|
+
category: 'db-schema',
|
|
179
|
+
type: 'missing',
|
|
180
|
+
entry: `Schema file "${file}" exists but is not documented`,
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const model of docModels) {
|
|
186
|
+
if (!actualModels.has(model)) {
|
|
187
|
+
issues.push({
|
|
188
|
+
category: 'db-schema',
|
|
189
|
+
type: 'stale',
|
|
190
|
+
entry: `Model "${model}" in ${file} is documented but no longer exists`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const model of actualModels) {
|
|
196
|
+
if (!docModels.has(model)) {
|
|
197
|
+
issues.push({
|
|
198
|
+
category: 'db-schema',
|
|
199
|
+
type: 'missing',
|
|
200
|
+
entry: `Model "${model}" in ${file} exists but is not documented`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function main(): void {
|
|
208
|
+
const issues: FreshnessIssue[] = [];
|
|
209
|
+
|
|
210
|
+
console.log('Checking documentation freshness...\n');
|
|
211
|
+
|
|
212
|
+
checkApiRoutes(issues);
|
|
213
|
+
checkDbSchema(issues);
|
|
214
|
+
|
|
215
|
+
if (issues.length === 0) {
|
|
216
|
+
console.log('All documentation is up to date.');
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const stale = issues.filter((i) => i.type === 'stale');
|
|
221
|
+
const missing = issues.filter((i) => i.type === 'missing');
|
|
222
|
+
|
|
223
|
+
if (stale.length > 0) {
|
|
224
|
+
console.log(`--- Stale entries (${stale.length}) ---\n`);
|
|
225
|
+
for (const issue of stale) {
|
|
226
|
+
console.log(` [${issue.category}] ${issue.entry}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (missing.length > 0) {
|
|
231
|
+
console.log(`\n--- Missing entries (${missing.length}) ---\n`);
|
|
232
|
+
for (const issue of missing) {
|
|
233
|
+
console.log(` [${issue.category}] ${issue.entry}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(`\nTotal: ${issues.length} freshness issue${issues.length > 1 ? 's' : ''}.`);
|
|
238
|
+
console.log('Run `yarn generate:docs` to regenerate documentation.');
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
main();
|