@onion-ai/cli 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +529 -0
  3. package/bin/onion.js +6 -0
  4. package/framework/CLAUDE.md +45 -0
  5. package/framework/VERSION +1 -0
  6. package/framework/agents/compliance/iso-22301-specialist.md +985 -0
  7. package/framework/agents/compliance/iso-27001-specialist.md +713 -0
  8. package/framework/agents/compliance/pmbok-specialist.md +739 -0
  9. package/framework/agents/compliance/security-information-master.md +907 -0
  10. package/framework/agents/compliance/soc2-specialist.md +889 -0
  11. package/framework/agents/deployment/docker-specialist.md +1192 -0
  12. package/framework/agents/development/c4-architecture-specialist.md +745 -0
  13. package/framework/agents/development/c4-documentation-specialist.md +695 -0
  14. package/framework/agents/development/clickup-specialist.md +396 -0
  15. package/framework/agents/development/cursor-specialist.md +277 -0
  16. package/framework/agents/development/docs-reverse-engineer.md +417 -0
  17. package/framework/agents/development/gamma-api-specialist.md +1168 -0
  18. package/framework/agents/development/gitflow-specialist.md +1206 -0
  19. package/framework/agents/development/linux-security-specialist.md +675 -0
  20. package/framework/agents/development/mermaid-specialist.md +515 -0
  21. package/framework/agents/development/nodejs-specialist.md +672 -0
  22. package/framework/agents/development/nx-migration-specialist.md +866 -0
  23. package/framework/agents/development/nx-monorepo-specialist.md +618 -0
  24. package/framework/agents/development/postgres-specialist.md +1123 -0
  25. package/framework/agents/development/react-developer.md +131 -0
  26. package/framework/agents/development/runflow-specialist.md +277 -0
  27. package/framework/agents/development/system-documentation-orchestrator.md +1387 -0
  28. package/framework/agents/development/task-specialist.md +677 -0
  29. package/framework/agents/git/branch-code-reviewer.md +225 -0
  30. package/framework/agents/git/branch-documentation-writer.md +161 -0
  31. package/framework/agents/git/branch-metaspec-checker.md +67 -0
  32. package/framework/agents/git/branch-test-planner.md +176 -0
  33. package/framework/agents/meta/agent-creator-specialist.md +1266 -0
  34. package/framework/agents/meta/command-creator-specialist.md +1676 -0
  35. package/framework/agents/meta/metaspec-gate-keeper.md +240 -0
  36. package/framework/agents/meta/onion.md +824 -0
  37. package/framework/agents/product/branding-positioning-specialist.md +1029 -0
  38. package/framework/agents/product/extract-meeting-specialist.md +394 -0
  39. package/framework/agents/product/meeting-consolidator.md +482 -0
  40. package/framework/agents/product/pain-price-specialist.md +508 -0
  41. package/framework/agents/product/presentation-orchestrator.md +1190 -0
  42. package/framework/agents/product/product-agent.md +201 -0
  43. package/framework/agents/product/story-points-framework-specialist.md +538 -0
  44. package/framework/agents/product/storytelling-business-specialist.md +890 -0
  45. package/framework/agents/research/research-agent.md +292 -0
  46. package/framework/agents/review/code-reviewer.md +154 -0
  47. package/framework/agents/review/corporate-compliance-specialist.md +370 -0
  48. package/framework/agents/testing/test-agent.md +424 -0
  49. package/framework/agents/testing/test-engineer.md +294 -0
  50. package/framework/agents/testing/test-planner.md +117 -0
  51. package/framework/commands/common/prompts/README.md +208 -0
  52. package/framework/commands/common/prompts/clickup-patterns.md +144 -0
  53. package/framework/commands/common/prompts/code-review-checklist.md +168 -0
  54. package/framework/commands/common/prompts/git-workflow-patterns.md +235 -0
  55. package/framework/commands/common/prompts/output-formats.md +240 -0
  56. package/framework/commands/common/prompts/technical.md +194 -0
  57. package/framework/commands/common/templates/abstraction-template.md +399 -0
  58. package/framework/commands/common/templates/agent-template.md +353 -0
  59. package/framework/commands/common/templates/business_context_template.md +748 -0
  60. package/framework/commands/common/templates/command-template.md +273 -0
  61. package/framework/commands/common/templates/technical_context_template.md +526 -0
  62. package/framework/commands/design/screen-spec.md +505 -0
  63. package/framework/commands/development/runflow-dev.md +465 -0
  64. package/framework/commands/docs/build-business-docs.md +299 -0
  65. package/framework/commands/docs/build-compliance-docs.md +143 -0
  66. package/framework/commands/docs/build-index.md +119 -0
  67. package/framework/commands/docs/build-tech-docs.md +221 -0
  68. package/framework/commands/docs/docs-health.md +141 -0
  69. package/framework/commands/docs/help.md +278 -0
  70. package/framework/commands/docs/refine-vision.md +25 -0
  71. package/framework/commands/docs/reverse-consolidate.md +158 -0
  72. package/framework/commands/docs/sync-sessions.md +354 -0
  73. package/framework/commands/docs/validate-docs.md +157 -0
  74. package/framework/commands/engineer/bump.md +29 -0
  75. package/framework/commands/engineer/docs.md +11 -0
  76. package/framework/commands/engineer/hotfix.md +183 -0
  77. package/framework/commands/engineer/plan.md +85 -0
  78. package/framework/commands/engineer/pr-update.md +219 -0
  79. package/framework/commands/engineer/pr.md +117 -0
  80. package/framework/commands/engineer/pre-pr.md +81 -0
  81. package/framework/commands/engineer/start.md +254 -0
  82. package/framework/commands/engineer/validate-phase-sync.md +134 -0
  83. package/framework/commands/engineer/warm-up.md +20 -0
  84. package/framework/commands/engineer/work.md +155 -0
  85. package/framework/commands/f/company-context-extractor.md +93 -0
  86. package/framework/commands/f/process-meetings.md +103 -0
  87. package/framework/commands/git/README.md +682 -0
  88. package/framework/commands/git/code-review.md +213 -0
  89. package/framework/commands/git/fast-commit.md +43 -0
  90. package/framework/commands/git/feature/finish.md +88 -0
  91. package/framework/commands/git/feature/publish.md +89 -0
  92. package/framework/commands/git/feature/start.md +172 -0
  93. package/framework/commands/git/help.md +100 -0
  94. package/framework/commands/git/hotfix/finish.md +96 -0
  95. package/framework/commands/git/hotfix/start.md +92 -0
  96. package/framework/commands/git/init.md +111 -0
  97. package/framework/commands/git/release/finish.md +96 -0
  98. package/framework/commands/git/release/start.md +93 -0
  99. package/framework/commands/git/sync.md +199 -0
  100. package/framework/commands/meta/all-tools.md +58 -0
  101. package/framework/commands/meta/analyze-complex-problem.md +186 -0
  102. package/framework/commands/meta/create-abstraction.md +882 -0
  103. package/framework/commands/meta/create-agent-express.md +98 -0
  104. package/framework/commands/meta/create-agent.md +210 -0
  105. package/framework/commands/meta/create-command.md +203 -0
  106. package/framework/commands/meta/create-knowledge-base.md +143 -0
  107. package/framework/commands/meta/create-task-structure.md +150 -0
  108. package/framework/commands/meta/setup-integration.md +274 -0
  109. package/framework/commands/onion.md +169 -0
  110. package/framework/commands/product/README.md +249 -0
  111. package/framework/commands/product/analyze-pain-price.md +694 -0
  112. package/framework/commands/product/branding.md +458 -0
  113. package/framework/commands/product/check.md +46 -0
  114. package/framework/commands/product/checklist-sync.md +239 -0
  115. package/framework/commands/product/collect.md +95 -0
  116. package/framework/commands/product/consolidate-meetings.md +291 -0
  117. package/framework/commands/product/estimate.md +511 -0
  118. package/framework/commands/product/extract-meeting.md +226 -0
  119. package/framework/commands/product/feature.md +416 -0
  120. package/framework/commands/product/light-arch.md +82 -0
  121. package/framework/commands/product/presentation.md +174 -0
  122. package/framework/commands/product/refine.md +161 -0
  123. package/framework/commands/product/spec.md +79 -0
  124. package/framework/commands/product/task-check.md +378 -0
  125. package/framework/commands/product/task.md +603 -0
  126. package/framework/commands/product/validate-task.md +325 -0
  127. package/framework/commands/product/warm-up.md +24 -0
  128. package/framework/commands/quick/analisys.md +17 -0
  129. package/framework/commands/test/e2e.md +377 -0
  130. package/framework/commands/test/integration.md +508 -0
  131. package/framework/commands/test/unit.md +381 -0
  132. package/framework/commands/validate/collab/pair-testing.md +657 -0
  133. package/framework/commands/validate/collab/three-amigos.md +534 -0
  134. package/framework/commands/validate/qa-points/estimate.md +660 -0
  135. package/framework/commands/validate/test-strategy/analyze.md +1201 -0
  136. package/framework/commands/validate/test-strategy/create.md +411 -0
  137. package/framework/commands/validate/workflow.md +370 -0
  138. package/framework/commands/warm-up.md +20 -0
  139. package/framework/docs/architecture/acoplamento-clickup-problema-analise.md +468 -0
  140. package/framework/docs/architecture/desacoplamento-roadmap.md +364 -0
  141. package/framework/docs/architecture/validacao-fase-1.md +235 -0
  142. package/framework/docs/c4/c4-detection-rules.md +395 -0
  143. package/framework/docs/c4/c4-documentation-templates.md +579 -0
  144. package/framework/docs/c4/c4-mermaid-patterns.md +331 -0
  145. package/framework/docs/c4/c4-templates.md +256 -0
  146. package/framework/docs/clickup/clickup-acceptance-criteria-strategy.md +329 -0
  147. package/framework/docs/clickup/clickup-auto-update-strategy.md +340 -0
  148. package/framework/docs/clickup/clickup-comment-formatter.md +239 -0
  149. package/framework/docs/clickup/clickup-description-fix.md +384 -0
  150. package/framework/docs/clickup/clickup-dual-comment-strategy.md +528 -0
  151. package/framework/docs/clickup/clickup-formatting.md +302 -0
  152. package/framework/docs/clickup/separador-tamanho-otimizado.md +258 -0
  153. package/framework/docs/engineer/pre-pr-acceptance-validation.md +256 -0
  154. package/framework/docs/onion/ESPERANTO.md +293 -0
  155. package/framework/docs/onion/agents-reference.md +832 -0
  156. package/framework/docs/onion/clickup-integration.md +780 -0
  157. package/framework/docs/onion/commands-guide.md +924 -0
  158. package/framework/docs/onion/engineering-flows.md +900 -0
  159. package/framework/docs/onion/getting-started.md +803 -0
  160. package/framework/docs/onion/maintenance-checklist.md +421 -0
  161. package/framework/docs/onion/naming-conventions.md +286 -0
  162. package/framework/docs/onion/practical-examples.md +854 -0
  163. package/framework/docs/product/story-points-integration.md +269 -0
  164. package/framework/docs/product/story-points-validation.md +237 -0
  165. package/framework/docs/reviews/task-manager-docs-review-2025-11-24.md +184 -0
  166. package/framework/docs/strategies/clickup-comment-patterns.md +766 -0
  167. package/framework/docs/strategies/clickup-integration-tests.md +602 -0
  168. package/framework/docs/strategies/clickup-mcp-wrappers-tests.md +888 -0
  169. package/framework/docs/strategies/clickup-regression-tests.md +587 -0
  170. package/framework/docs/strategies/visual-patterns.md +315 -0
  171. package/framework/docs/templates/README.md +649 -0
  172. package/framework/docs/templates/adr-template.md +226 -0
  173. package/framework/docs/templates/analysis-template.md +280 -0
  174. package/framework/docs/templates/execution-plan-template.md +430 -0
  175. package/framework/docs/templates/guide-template.md +367 -0
  176. package/framework/docs/templates/phase-execution-prompt-template.md +504 -0
  177. package/framework/docs/templates/reference-template.md +522 -0
  178. package/framework/docs/templates/solution-template.md +390 -0
  179. package/framework/docs/tools/README.md +356 -0
  180. package/framework/docs/tools/agents.md +365 -0
  181. package/framework/docs/tools/commands.md +669 -0
  182. package/framework/docs/tools/cursor.md +539 -0
  183. package/framework/docs/tools/mcps.md +937 -0
  184. package/framework/docs/tools/rules.md +461 -0
  185. package/framework/rules/language-and-documentation.mdc +371 -0
  186. package/framework/rules/nestjs-controllers.md +83 -0
  187. package/framework/rules/nestjs-dtos.md +255 -0
  188. package/framework/rules/nestjs-modules.md +141 -0
  189. package/framework/rules/nestjs-services.md +230 -0
  190. package/framework/rules/nx-rules.mdc +41 -0
  191. package/framework/rules/onion-patterns.mdc +197 -0
  192. package/framework/skills/codebase-visualizer/SKILL.md +26 -0
  193. package/framework/skills/codebase-visualizer/scripts/visualize.py +131 -0
  194. package/framework/skills/collect/SKILL.md +84 -0
  195. package/framework/skills/create-rule/SKILL.md +152 -0
  196. package/framework/skills/db-schema-visualizer/SKILL.md +49 -0
  197. package/framework/skills/db-schema-visualizer/scripts/visualize.py +1191 -0
  198. package/framework/skills/sync-meetings/SKILL.md +239 -0
  199. package/framework/utils/clickup-mcp-wrappers.md +744 -0
  200. package/framework/utils/date-time-standards.md +200 -0
  201. package/framework/utils/task-manager/README.md +94 -0
  202. package/framework/utils/task-manager/adapters/asana.md +377 -0
  203. package/framework/utils/task-manager/adapters/clickup.md +467 -0
  204. package/framework/utils/task-manager/adapters/linear.md +421 -0
  205. package/framework/utils/task-manager/detector.md +299 -0
  206. package/framework/utils/task-manager/factory.md +363 -0
  207. package/framework/utils/task-manager/interface.md +248 -0
  208. package/framework/utils/task-manager/types.md +409 -0
  209. package/package.json +41 -0
  210. package/src/cli.js +73 -0
  211. package/src/commands/doctor.js +191 -0
  212. package/src/commands/init.js +287 -0
  213. package/src/commands/install.js +261 -0
  214. package/src/commands/list.js +152 -0
  215. package/src/commands/uninstall.js +90 -0
  216. package/src/commands/update.js +26 -0
  217. package/src/utils/fs.js +89 -0
  218. package/src/utils/log.js +35 -0
  219. package/src/utils/paths.js +32 -0
  220. package/src/utils/prompt.js +76 -0
@@ -0,0 +1,1191 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Database Schema Visualizer
4
+ Parses Prisma, TypeORM, or SQL schemas and generates an interactive HTML ERD.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+ import webbrowser
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+
18
+ @dataclass
19
+ class Column:
20
+ name: str
21
+ data_type: str
22
+ is_primary: bool = False
23
+ is_nullable: bool = False
24
+ is_unique: bool = False
25
+ default: Optional[str] = None
26
+ is_relation: bool = False
27
+
28
+
29
+ @dataclass
30
+ class Relationship:
31
+ from_table: str
32
+ from_column: str
33
+ to_table: str
34
+ to_column: str
35
+ rel_type: str # "one-to-one", "one-to-many", "many-to-many"
36
+
37
+
38
+ @dataclass
39
+ class Table:
40
+ name: str
41
+ columns: list = field(default_factory=list)
42
+
43
+
44
+ @dataclass
45
+ class Schema:
46
+ tables: list = field(default_factory=list)
47
+ relationships: list = field(default_factory=list)
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Prisma Parser
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def parse_prisma(file_paths: list[str]) -> Schema:
55
+ schema = Schema()
56
+ full_text = ""
57
+ for fp in file_paths:
58
+ with open(fp, "r") as f:
59
+ full_text += f.read() + "\n"
60
+
61
+ # Extract enums for reference
62
+ enums = set(re.findall(r'enum\s+(\w+)\s*\{', full_text))
63
+
64
+ # Extract models
65
+ model_blocks = re.finditer(
66
+ r'model\s+(\w+)\s*\{(.*?)\}', full_text, re.DOTALL
67
+ )
68
+
69
+ tables_by_name = {}
70
+
71
+ for match in model_blocks:
72
+ model_name = match.group(1)
73
+ body = match.group(2)
74
+ table = Table(name=model_name)
75
+
76
+ for line in body.strip().split("\n"):
77
+ line = line.strip()
78
+ if not line or line.startswith("//") or line.startswith("@@"):
79
+ continue
80
+
81
+ parts = line.split()
82
+ if len(parts) < 2:
83
+ continue
84
+
85
+ col_name = parts[0]
86
+ if col_name.startswith("@@") or col_name.startswith("//"):
87
+ continue
88
+
89
+ raw_type = parts[1]
90
+ is_nullable = raw_type.endswith("?")
91
+ is_array = raw_type.endswith("[]")
92
+ clean_type = raw_type.rstrip("?").rstrip("[]")
93
+
94
+ # Determine if this is a relation field
95
+ is_relation = clean_type in tables_by_name or clean_type not in (
96
+ "String", "Int", "Float", "Boolean", "DateTime", "BigInt",
97
+ "Decimal", "Bytes", "Json"
98
+ ) and clean_type not in enums and not clean_type.startswith("Unsupported")
99
+
100
+ # Check for @id
101
+ is_primary = "@id" in line
102
+
103
+ # Check for @unique
104
+ is_unique = "@unique" in line
105
+
106
+ # Check for @default
107
+ default_match = re.search(r'@default\(([^)]+)\)', line)
108
+ default_val = default_match.group(1) if default_match else None
109
+
110
+ # Check for @relation
111
+ relation_match = re.search(
112
+ r'@relation\([^)]*fields:\s*\[([^\]]+)\][^)]*references:\s*\[([^\]]+)\]',
113
+ line
114
+ )
115
+
116
+ if relation_match:
117
+ fk_fields = [f.strip() for f in relation_match.group(1).split(",")]
118
+ ref_fields = [f.strip() for f in relation_match.group(2).split(",")]
119
+ for fk, ref in zip(fk_fields, ref_fields):
120
+ rel_type = "one-to-many" if not is_array else "many-to-many"
121
+ schema.relationships.append(Relationship(
122
+ from_table=model_name,
123
+ from_column=fk,
124
+ to_table=clean_type,
125
+ to_column=ref,
126
+ rel_type=rel_type,
127
+ ))
128
+ # Skip adding relation fields as columns (they're virtual)
129
+ continue
130
+
131
+ if is_relation and not relation_match:
132
+ # Virtual relation field without @relation (implicit)
133
+ continue
134
+
135
+ display_type = raw_type
136
+ if clean_type in enums:
137
+ display_type = f"enum({clean_type})"
138
+
139
+ col = Column(
140
+ name=col_name,
141
+ data_type=display_type,
142
+ is_primary=is_primary,
143
+ is_nullable=is_nullable,
144
+ is_unique=is_unique,
145
+ default=default_val,
146
+ )
147
+ table.columns.append(col)
148
+
149
+ tables_by_name[model_name] = table
150
+ schema.tables.append(table)
151
+
152
+ # Second pass: re-check relation types for models discovered after first encounter
153
+ full_model_names = {t.name for t in schema.tables}
154
+ # Re-parse to catch relations to models defined later
155
+ schema_pass2 = Schema()
156
+ schema_pass2.tables = schema.tables
157
+ schema_pass2.relationships = schema.relationships
158
+
159
+ return schema_pass2
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # SQL Parser
164
+ # ---------------------------------------------------------------------------
165
+
166
+ def parse_sql(directory: str) -> Schema:
167
+ schema = Schema()
168
+ full_sql = ""
169
+
170
+ sql_dir = Path(directory)
171
+ sql_files = sorted(sql_dir.rglob("*.sql"))
172
+ for sf in sql_files:
173
+ with open(sf, "r") as f:
174
+ full_sql += f.read() + "\n"
175
+
176
+ # Parse CREATE TABLE statements
177
+ create_stmts = re.finditer(
178
+ r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s*\((.*?)\)\s*;',
179
+ full_sql, re.DOTALL | re.IGNORECASE
180
+ )
181
+
182
+ for match in create_stmts:
183
+ table_name = match.group(1)
184
+ body = match.group(2)
185
+ table = Table(name=table_name)
186
+
187
+ # Split by comma, but respect parentheses
188
+ parts = _split_sql_columns(body)
189
+
190
+ for part in parts:
191
+ part = part.strip()
192
+ if not part:
193
+ continue
194
+
195
+ upper = part.upper().strip()
196
+
197
+ # Table-level constraints
198
+ if upper.startswith("PRIMARY KEY"):
199
+ pk_cols = re.findall(r'["`]?(\w+)["`]?', part.split("(", 1)[1].split(")")[0])
200
+ for c in table.columns:
201
+ if c.name in pk_cols:
202
+ c.is_primary = True
203
+ continue
204
+
205
+ if upper.startswith("FOREIGN KEY"):
206
+ fk_match = re.search(
207
+ r'FOREIGN\s+KEY\s*\(["`]?(\w+)["`]?\)\s*REFERENCES\s+["`]?(\w+)["`]?\s*\(["`]?(\w+)["`]?\)',
208
+ part, re.IGNORECASE
209
+ )
210
+ if fk_match:
211
+ schema.relationships.append(Relationship(
212
+ from_table=table_name,
213
+ from_column=fk_match.group(1),
214
+ to_table=fk_match.group(2),
215
+ to_column=fk_match.group(3),
216
+ rel_type="one-to-many",
217
+ ))
218
+ continue
219
+
220
+ if upper.startswith(("UNIQUE", "INDEX", "KEY", "CONSTRAINT", "CHECK")):
221
+ continue
222
+
223
+ # Column definition
224
+ col_match = re.match(r'["`]?(\w+)["`]?\s+(.+)', part, re.IGNORECASE)
225
+ if not col_match:
226
+ continue
227
+
228
+ col_name = col_match.group(1)
229
+ rest = col_match.group(2)
230
+
231
+ # Extract type (first word or word with parens)
232
+ type_match = re.match(r'(\w+(?:\([^)]*\))?)', rest)
233
+ col_type = type_match.group(1) if type_match else rest.split()[0]
234
+
235
+ is_primary = bool(re.search(r'PRIMARY\s+KEY', rest, re.IGNORECASE))
236
+ is_nullable = "NOT NULL" not in rest.upper()
237
+ is_unique = bool(re.search(r'\bUNIQUE\b', rest, re.IGNORECASE))
238
+
239
+ default_match = re.search(r"DEFAULT\s+('?[^',)]+[']?)", rest, re.IGNORECASE)
240
+ default_val = default_match.group(1) if default_match else None
241
+
242
+ # Inline REFERENCES
243
+ ref_match = re.search(
244
+ r'REFERENCES\s+["`]?(\w+)["`]?\s*\(["`]?(\w+)["`]?\)', rest, re.IGNORECASE
245
+ )
246
+ if ref_match:
247
+ schema.relationships.append(Relationship(
248
+ from_table=table_name,
249
+ from_column=col_name,
250
+ to_table=ref_match.group(1),
251
+ to_column=ref_match.group(2),
252
+ rel_type="one-to-many",
253
+ ))
254
+
255
+ col = Column(
256
+ name=col_name,
257
+ data_type=col_type,
258
+ is_primary=is_primary,
259
+ is_nullable=is_nullable,
260
+ is_unique=is_unique,
261
+ default=default_val,
262
+ )
263
+ table.columns.append(col)
264
+
265
+ schema.tables.append(table)
266
+
267
+ # Also parse ALTER TABLE ... ADD FOREIGN KEY
268
+ alter_fks = re.finditer(
269
+ r'ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+(?:CONSTRAINT\s+\w+\s+)?FOREIGN\s+KEY\s*\(["`]?(\w+)["`]?\)\s*REFERENCES\s+["`]?(\w+)["`]?\s*\(["`]?(\w+)["`]?\)',
270
+ full_sql, re.IGNORECASE
271
+ )
272
+ for m in alter_fks:
273
+ schema.relationships.append(Relationship(
274
+ from_table=m.group(1),
275
+ from_column=m.group(2),
276
+ to_table=m.group(3),
277
+ to_column=m.group(4),
278
+ rel_type="one-to-many",
279
+ ))
280
+
281
+ return schema
282
+
283
+
284
+ def _split_sql_columns(body: str) -> list[str]:
285
+ """Split SQL column definitions respecting parentheses."""
286
+ parts = []
287
+ depth = 0
288
+ current = []
289
+ for char in body:
290
+ if char == '(':
291
+ depth += 1
292
+ current.append(char)
293
+ elif char == ')':
294
+ depth -= 1
295
+ current.append(char)
296
+ elif char == ',' and depth == 0:
297
+ parts.append(''.join(current))
298
+ current = []
299
+ else:
300
+ current.append(char)
301
+ if current:
302
+ parts.append(''.join(current))
303
+ return parts
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # TypeORM Parser
308
+ # ---------------------------------------------------------------------------
309
+
310
+ def parse_typeorm(directory: str) -> Schema:
311
+ schema = Schema()
312
+ entities_dir = Path(directory)
313
+ ts_files = sorted(entities_dir.rglob("*.ts"))
314
+
315
+ for tf in ts_files:
316
+ with open(tf, "r") as f:
317
+ content = f.read()
318
+
319
+ # Find @Entity() decorated classes
320
+ entity_match = re.search(
321
+ r'@Entity\([^)]*\)\s*(?:export\s+)?class\s+(\w+)',
322
+ content
323
+ )
324
+ if not entity_match:
325
+ continue
326
+
327
+ class_name = entity_match.group(1)
328
+ # Try to get table name from @Entity('name') or @Entity({ name: 'name' })
329
+ table_name_match = re.search(r"@Entity\(['\"](\w+)['\"]\)", content)
330
+ if not table_name_match:
331
+ table_name_match = re.search(r"@Entity\(\{[^}]*name:\s*['\"](\w+)['\"]", content)
332
+ table_name = table_name_match.group(1) if table_name_match else class_name
333
+
334
+ table = Table(name=table_name)
335
+
336
+ # Find columns with @PrimaryGeneratedColumn or @PrimaryColumn
337
+ for pm in re.finditer(
338
+ r'@(?:PrimaryGeneratedColumn|PrimaryColumn)\([^)]*\)\s*(\w+)\s*[!?]?\s*:\s*(\w+)',
339
+ content
340
+ ):
341
+ table.columns.append(Column(
342
+ name=pm.group(1),
343
+ data_type=pm.group(2),
344
+ is_primary=True,
345
+ ))
346
+
347
+ # Find @Column() fields
348
+ for cm in re.finditer(
349
+ r'@Column\(([^)]*)\)\s*(\w+)\s*[!?]?\s*:\s*(\w+)',
350
+ content
351
+ ):
352
+ opts = cm.group(1)
353
+ col_name = cm.group(2)
354
+ col_type = cm.group(3)
355
+
356
+ is_nullable = "nullable: true" in opts or "nullable:true" in opts
357
+ is_unique = "unique: true" in opts or "unique:true" in opts
358
+ default_match = re.search(r"default:\s*['\"]?([^'\"}, ]+)", opts)
359
+
360
+ table.columns.append(Column(
361
+ name=col_name,
362
+ data_type=col_type,
363
+ is_nullable=is_nullable,
364
+ is_unique=is_unique,
365
+ default=default_match.group(1) if default_match else None,
366
+ ))
367
+
368
+ # Find relationships
369
+ for rel_match in re.finditer(
370
+ r'@(ManyToOne|OneToMany|OneToOne|ManyToMany)\(\s*\(\)\s*=>\s*(\w+)',
371
+ content
372
+ ):
373
+ rel_decorator = rel_match.group(1)
374
+ target_entity = rel_match.group(2)
375
+
376
+ rel_type_map = {
377
+ "ManyToOne": "one-to-many",
378
+ "OneToMany": "one-to-many",
379
+ "OneToOne": "one-to-one",
380
+ "ManyToMany": "many-to-many",
381
+ }
382
+
383
+ # Find JoinColumn if present
384
+ join_col_match = re.search(
385
+ r'@JoinColumn\(\{[^}]*name:\s*[\'"](\w+)[\'"]',
386
+ content[rel_match.end():rel_match.end() + 200]
387
+ )
388
+ from_col = join_col_match.group(1) if join_col_match else f"{target_entity.lower()}_id"
389
+
390
+ if rel_decorator in ("ManyToOne", "OneToOne"):
391
+ schema.relationships.append(Relationship(
392
+ from_table=table_name,
393
+ from_column=from_col,
394
+ to_table=target_entity,
395
+ to_column="id",
396
+ rel_type=rel_type_map[rel_decorator],
397
+ ))
398
+
399
+ schema.tables.append(table)
400
+
401
+ return schema
402
+
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # HTML Generator
406
+ # ---------------------------------------------------------------------------
407
+
408
+ def schema_to_json(schema: Schema) -> str:
409
+ data = {
410
+ "tables": [],
411
+ "relationships": [],
412
+ }
413
+ for t in schema.tables:
414
+ cols = []
415
+ for c in t.columns:
416
+ cols.append({
417
+ "name": c.name,
418
+ "type": c.data_type,
419
+ "pk": c.is_primary,
420
+ "nullable": c.is_nullable,
421
+ "unique": c.is_unique,
422
+ "default": c.default,
423
+ })
424
+ data["tables"].append({"name": t.name, "columns": cols})
425
+
426
+ for r in schema.relationships:
427
+ data["relationships"].append({
428
+ "from": r.from_table,
429
+ "fromCol": r.from_column,
430
+ "to": r.to_table,
431
+ "toCol": r.to_column,
432
+ "type": r.rel_type,
433
+ })
434
+
435
+ return json.dumps(data, indent=2)
436
+
437
+
438
+ def generate_html(schema: Schema, output_path: str):
439
+ schema_json = schema_to_json(schema)
440
+
441
+ html = f"""<!DOCTYPE html>
442
+ <html lang="en">
443
+ <head>
444
+ <meta charset="UTF-8">
445
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
446
+ <title>Database Schema — ERD</title>
447
+ <style>
448
+ :root {{
449
+ --bg: #0f1117;
450
+ --surface: #1a1d27;
451
+ --surface-hover: #222632;
452
+ --border: #2a2e3a;
453
+ --text: #e2e4e9;
454
+ --text-dim: #8b8fa3;
455
+ --primary: #6c8dfa;
456
+ --primary-dim: #4a6ad4;
457
+ --pk: #f0b429;
458
+ --fk: #6c8dfa;
459
+ --nullable: #8b8fa3;
460
+ --unique: #a78bfa;
461
+ --rel-one: #34d399;
462
+ --rel-many: #f97316;
463
+ --rel-mm: #ec4899;
464
+ --shadow: 0 4px 24px rgba(0,0,0,0.4);
465
+ }}
466
+
467
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
468
+
469
+ body {{
470
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
471
+ background: var(--bg);
472
+ color: var(--text);
473
+ overflow: hidden;
474
+ height: 100vh;
475
+ width: 100vw;
476
+ }}
477
+
478
+ #toolbar {{
479
+ position: fixed;
480
+ top: 0;
481
+ left: 0;
482
+ right: 0;
483
+ height: 52px;
484
+ background: var(--surface);
485
+ border-bottom: 1px solid var(--border);
486
+ display: flex;
487
+ align-items: center;
488
+ padding: 0 20px;
489
+ gap: 16px;
490
+ z-index: 100;
491
+ box-shadow: var(--shadow);
492
+ }}
493
+
494
+ #toolbar h1 {{
495
+ font-size: 14px;
496
+ font-weight: 600;
497
+ color: var(--text);
498
+ white-space: nowrap;
499
+ }}
500
+
501
+ #toolbar .stats {{
502
+ font-size: 11px;
503
+ color: var(--text-dim);
504
+ white-space: nowrap;
505
+ }}
506
+
507
+ #search {{
508
+ background: var(--bg);
509
+ border: 1px solid var(--border);
510
+ color: var(--text);
511
+ padding: 6px 12px;
512
+ border-radius: 6px;
513
+ font-size: 12px;
514
+ font-family: inherit;
515
+ width: 220px;
516
+ outline: none;
517
+ transition: border-color 0.2s;
518
+ }}
519
+ #search:focus {{ border-color: var(--primary); }}
520
+ #search::placeholder {{ color: var(--text-dim); }}
521
+
522
+ .toolbar-btn {{
523
+ background: var(--bg);
524
+ border: 1px solid var(--border);
525
+ color: var(--text-dim);
526
+ padding: 6px 12px;
527
+ border-radius: 6px;
528
+ font-size: 11px;
529
+ font-family: inherit;
530
+ cursor: pointer;
531
+ transition: all 0.2s;
532
+ white-space: nowrap;
533
+ }}
534
+ .toolbar-btn:hover {{ border-color: var(--primary); color: var(--text); }}
535
+
536
+ .spacer {{ flex: 1; }}
537
+
538
+ #canvas {{
539
+ position: absolute;
540
+ top: 52px;
541
+ left: 0;
542
+ right: 0;
543
+ bottom: 0;
544
+ overflow: hidden;
545
+ cursor: grab;
546
+ }}
547
+ #canvas:active {{ cursor: grabbing; }}
548
+
549
+ #world {{
550
+ position: absolute;
551
+ transform-origin: 0 0;
552
+ }}
553
+
554
+ .table-node {{
555
+ position: absolute;
556
+ background: var(--surface);
557
+ border: 1px solid var(--border);
558
+ border-radius: 10px;
559
+ min-width: 240px;
560
+ max-width: 340px;
561
+ box-shadow: var(--shadow);
562
+ cursor: move;
563
+ user-select: none;
564
+ transition: border-color 0.2s, box-shadow 0.2s;
565
+ }}
566
+ .table-node:hover {{ border-color: var(--primary-dim); }}
567
+ .table-node.highlight {{
568
+ border-color: var(--primary);
569
+ box-shadow: 0 0 0 2px var(--primary), var(--shadow);
570
+ }}
571
+ .table-node.dimmed {{ opacity: 0.25; }}
572
+
573
+ .table-header {{
574
+ padding: 10px 14px;
575
+ background: linear-gradient(135deg, rgba(108,141,250,0.1), rgba(108,141,250,0.03));
576
+ border-bottom: 1px solid var(--border);
577
+ border-radius: 10px 10px 0 0;
578
+ display: flex;
579
+ align-items: center;
580
+ gap: 8px;
581
+ }}
582
+
583
+ .table-icon {{
584
+ width: 18px; height: 18px;
585
+ fill: var(--primary);
586
+ flex-shrink: 0;
587
+ }}
588
+
589
+ .table-name {{
590
+ font-size: 13px;
591
+ font-weight: 700;
592
+ color: var(--primary);
593
+ letter-spacing: 0.3px;
594
+ }}
595
+
596
+ .col-count {{
597
+ margin-left: auto;
598
+ font-size: 10px;
599
+ color: var(--text-dim);
600
+ background: var(--bg);
601
+ padding: 2px 7px;
602
+ border-radius: 10px;
603
+ }}
604
+
605
+ .table-columns {{
606
+ padding: 6px 0;
607
+ }}
608
+
609
+ .col-row {{
610
+ padding: 4px 14px;
611
+ display: flex;
612
+ align-items: center;
613
+ gap: 8px;
614
+ font-size: 12px;
615
+ transition: background 0.15s;
616
+ }}
617
+ .col-row:hover {{ background: var(--surface-hover); }}
618
+
619
+ .col-badge {{
620
+ font-size: 9px;
621
+ font-weight: 700;
622
+ padding: 1px 5px;
623
+ border-radius: 4px;
624
+ flex-shrink: 0;
625
+ letter-spacing: 0.5px;
626
+ }}
627
+ .badge-pk {{ background: rgba(240,180,41,0.15); color: var(--pk); }}
628
+ .badge-fk {{ background: rgba(108,141,250,0.15); color: var(--fk); }}
629
+ .badge-uq {{ background: rgba(167,139,250,0.15); color: var(--unique); }}
630
+
631
+ .col-name {{ color: var(--text); flex-shrink: 0; }}
632
+ .col-type {{ color: var(--text-dim); font-size: 11px; margin-left: auto; }}
633
+ .col-nullable {{ color: var(--nullable); font-size: 10px; }}
634
+ .col-default {{ color: var(--text-dim); font-size: 10px; font-style: italic; }}
635
+
636
+ svg#relationships {{
637
+ position: absolute;
638
+ top: 0; left: 0;
639
+ pointer-events: none;
640
+ overflow: visible;
641
+ }}
642
+
643
+ .rel-path {{
644
+ fill: none;
645
+ stroke-width: 1.5;
646
+ opacity: 0.5;
647
+ transition: opacity 0.2s, stroke-width 0.2s;
648
+ }}
649
+ .rel-path.highlight {{
650
+ opacity: 1;
651
+ stroke-width: 2.5;
652
+ }}
653
+ .rel-path.dimmed {{ opacity: 0.08; }}
654
+
655
+ .rel-label {{
656
+ font-size: 10px;
657
+ fill: var(--text-dim);
658
+ pointer-events: none;
659
+ opacity: 0.7;
660
+ transition: opacity 0.2s;
661
+ }}
662
+ .rel-label.dimmed {{ opacity: 0.08; }}
663
+ .rel-label.highlight {{ opacity: 1; fill: var(--text); }}
664
+
665
+ #minimap {{
666
+ position: fixed;
667
+ bottom: 16px;
668
+ right: 16px;
669
+ width: 180px;
670
+ height: 120px;
671
+ background: var(--surface);
672
+ border: 1px solid var(--border);
673
+ border-radius: 8px;
674
+ overflow: hidden;
675
+ z-index: 50;
676
+ box-shadow: var(--shadow);
677
+ }}
678
+ #minimap canvas {{ width: 100%; height: 100%; }}
679
+
680
+ #legend {{
681
+ position: fixed;
682
+ bottom: 16px;
683
+ left: 16px;
684
+ background: var(--surface);
685
+ border: 1px solid var(--border);
686
+ border-radius: 8px;
687
+ padding: 12px 16px;
688
+ font-size: 11px;
689
+ z-index: 50;
690
+ box-shadow: var(--shadow);
691
+ }}
692
+ .legend-item {{
693
+ display: flex;
694
+ align-items: center;
695
+ gap: 8px;
696
+ margin-bottom: 4px;
697
+ }}
698
+ .legend-item:last-child {{ margin-bottom: 0; }}
699
+ .legend-line {{
700
+ width: 24px;
701
+ height: 2px;
702
+ border-radius: 1px;
703
+ }}
704
+ </style>
705
+ </head>
706
+ <body>
707
+
708
+ <div id="toolbar">
709
+ <svg class="table-icon" viewBox="0 0 24 24" style="width:20px;height:20px;fill:var(--primary)">
710
+ <path d="M3 3h18v18H3V3zm2 2v4h14V5H5zm0 6v2h6v-2H5zm8 0v2h6v-2h-6zm-8 4v4h6v-4H5zm8 0v4h6v-4h-6z"/>
711
+ </svg>
712
+ <h1>DB Schema</h1>
713
+ <span class="stats" id="stats"></span>
714
+ <div class="spacer"></div>
715
+ <input type="text" id="search" placeholder="Search tables..." />
716
+ <button class="toolbar-btn" onclick="resetView()">Reset View</button>
717
+ <button class="toolbar-btn" onclick="autoLayout()">Re-layout</button>
718
+ </div>
719
+
720
+ <div id="canvas">
721
+ <div id="world">
722
+ <svg id="relationships"></svg>
723
+ </div>
724
+ </div>
725
+
726
+ <div id="legend">
727
+ <div class="legend-item"><div class="legend-line" style="background:var(--rel-one)"></div><span>One-to-One</span></div>
728
+ <div class="legend-item"><div class="legend-line" style="background:var(--rel-many)"></div><span>One-to-Many</span></div>
729
+ <div class="legend-item"><div class="legend-line" style="background:var(--rel-mm)"></div><span>Many-to-Many</span></div>
730
+ </div>
731
+
732
+ <div id="minimap"><canvas id="minimap-canvas"></canvas></div>
733
+
734
+ <script>
735
+ const SCHEMA = {schema_json};
736
+
737
+ const world = document.getElementById('world');
738
+ const canvas = document.getElementById('canvas');
739
+ const svgRels = document.getElementById('relationships');
740
+ const searchInput = document.getElementById('search');
741
+ const statsEl = document.getElementById('stats');
742
+
743
+ let nodes = {{}};
744
+ let zoom = 1;
745
+ let panX = 60, panY = 80;
746
+ let dragging = null;
747
+ let dragStartX, dragStartY, dragNodeX, dragNodeY;
748
+ let isPanning = false;
749
+ let panStartX, panStartY, panStartPanX, panStartPanY;
750
+ let selectedTable = null;
751
+
752
+ const relColors = {{
753
+ 'one-to-one': 'var(--rel-one)',
754
+ 'one-to-many': 'var(--rel-many)',
755
+ 'many-to-many': 'var(--rel-mm)',
756
+ }};
757
+
758
+ // FK columns for badge display
759
+ const fkCols = new Set();
760
+ SCHEMA.relationships.forEach(r => fkCols.add(r.from + '.' + r.fromCol));
761
+
762
+ function init() {{
763
+ statsEl.textContent = SCHEMA.tables.length + ' tables · ' + SCHEMA.relationships.length + ' relationships';
764
+
765
+ SCHEMA.tables.forEach((t, i) => {{
766
+ const el = createTableNode(t);
767
+ world.appendChild(el);
768
+ nodes[t.name] = {{ el, x: 0, y: 0, w: 0, h: 0 }};
769
+ }});
770
+
771
+ autoLayout();
772
+ requestAnimationFrame(() => {{
773
+ Object.keys(nodes).forEach(name => {{
774
+ const rect = nodes[name].el.getBoundingClientRect();
775
+ nodes[name].w = rect.width;
776
+ nodes[name].h = rect.height;
777
+ }});
778
+ drawRelationships();
779
+ updateMinimap();
780
+ }});
781
+
782
+ setupPanZoom();
783
+
784
+ searchInput.addEventListener('input', (e) => {{
785
+ const q = e.target.value.toLowerCase();
786
+ Object.keys(nodes).forEach(name => {{
787
+ const match = !q || name.toLowerCase().includes(q);
788
+ nodes[name].el.style.display = match ? '' : 'none';
789
+ }});
790
+ drawRelationships();
791
+ }});
792
+ }}
793
+
794
+ function createTableNode(table) {{
795
+ const div = document.createElement('div');
796
+ div.className = 'table-node';
797
+ div.dataset.table = table.name;
798
+
799
+ let colsHtml = '';
800
+ table.columns.forEach(c => {{
801
+ let badges = '';
802
+ if (c.pk) badges += '<span class="col-badge badge-pk">PK</span>';
803
+ if (fkCols.has(table.name + '.' + c.name)) badges += '<span class="col-badge badge-fk">FK</span>';
804
+ if (c.unique) badges += '<span class="col-badge badge-uq">UQ</span>';
805
+
806
+ let extras = '';
807
+ if (c.nullable) extras += '<span class="col-nullable">?</span>';
808
+ if (c.default) extras += '<span class="col-default">=' + escHtml(c.default) + '</span>';
809
+
810
+ colsHtml += '<div class="col-row">' + badges +
811
+ '<span class="col-name">' + escHtml(c.name) + '</span>' +
812
+ extras +
813
+ '<span class="col-type">' + escHtml(c.type) + '</span></div>';
814
+ }});
815
+
816
+ div.innerHTML = `
817
+ <div class="table-header">
818
+ <svg class="table-icon" viewBox="0 0 24 24"><path d="M3 3h18v18H3V3zm2 2v4h14V5H5zm0 6v2h6v-2H5zm8 0v2h6v-2h-6zm-8 4v4h6v-4H5zm8 0v4h6v-4h-6z"/></svg>
819
+ <span class="table-name">${{escHtml(table.name)}}</span>
820
+ <span class="col-count">${{table.columns.length}}</span>
821
+ </div>
822
+ <div class="table-columns">${{colsHtml}}</div>`;
823
+
824
+ // Drag
825
+ div.addEventListener('mousedown', (e) => {{
826
+ if (e.button !== 0) return;
827
+ e.stopPropagation();
828
+ dragging = table.name;
829
+ const rect = div.getBoundingClientRect();
830
+ dragStartX = e.clientX;
831
+ dragStartY = e.clientY;
832
+ dragNodeX = nodes[table.name].x;
833
+ dragNodeY = nodes[table.name].y;
834
+ }});
835
+
836
+ // Click to select
837
+ div.addEventListener('click', (e) => {{
838
+ e.stopPropagation();
839
+ if (Math.abs(e.clientX - dragStartX) > 3 || Math.abs(e.clientY - dragStartY) > 3) return;
840
+ toggleSelect(table.name);
841
+ }});
842
+
843
+ return div;
844
+ }}
845
+
846
+ function toggleSelect(name) {{
847
+ if (selectedTable === name) {{
848
+ selectedTable = null;
849
+ Object.values(nodes).forEach(n => n.el.classList.remove('highlight', 'dimmed'));
850
+ svgRels.querySelectorAll('.rel-path, .rel-label').forEach(p => p.classList.remove('highlight', 'dimmed'));
851
+ }} else {{
852
+ selectedTable = name;
853
+ const connected = new Set([name]);
854
+ SCHEMA.relationships.forEach(r => {{
855
+ if (r.from === name) connected.add(r.to);
856
+ if (r.to === name) connected.add(r.from);
857
+ }});
858
+
859
+ Object.keys(nodes).forEach(n => {{
860
+ nodes[n].el.classList.toggle('highlight', n === name);
861
+ nodes[n].el.classList.toggle('dimmed', !connected.has(n));
862
+ }});
863
+
864
+ svgRels.querySelectorAll('.rel-path').forEach(p => {{
865
+ const from = p.dataset.from, to = p.dataset.to;
866
+ const isConn = from === name || to === name;
867
+ p.classList.toggle('highlight', isConn);
868
+ p.classList.toggle('dimmed', !isConn);
869
+ }});
870
+ svgRels.querySelectorAll('.rel-label').forEach(l => {{
871
+ const from = l.dataset.from, to = l.dataset.to;
872
+ const isConn = from === name || to === name;
873
+ l.classList.toggle('highlight', isConn);
874
+ l.classList.toggle('dimmed', !isConn);
875
+ }});
876
+ }}
877
+ }}
878
+
879
+ function autoLayout() {{
880
+ const cols = Math.ceil(Math.sqrt(SCHEMA.tables.length));
881
+ const padX = 320, padY = 60;
882
+
883
+ // Sort tables: those with more relationships first, for better layout
884
+ const relCount = {{}};
885
+ SCHEMA.tables.forEach(t => relCount[t.name] = 0);
886
+ SCHEMA.relationships.forEach(r => {{
887
+ relCount[r.from] = (relCount[r.from] || 0) + 1;
888
+ relCount[r.to] = (relCount[r.to] || 0) + 1;
889
+ }});
890
+ const sorted = [...SCHEMA.tables].sort((a, b) => (relCount[b.name] || 0) - (relCount[a.name] || 0));
891
+
892
+ sorted.forEach((t, i) => {{
893
+ const col = i % cols;
894
+ const row = Math.floor(i / cols);
895
+ const x = col * padX;
896
+ const y = row * (200 + padY);
897
+ if (nodes[t.name]) {{
898
+ nodes[t.name].x = x;
899
+ nodes[t.name].y = y;
900
+ nodes[t.name].el.style.left = x + 'px';
901
+ nodes[t.name].el.style.top = y + 'px';
902
+ }}
903
+ }});
904
+
905
+ requestAnimationFrame(() => {{
906
+ Object.keys(nodes).forEach(name => {{
907
+ const rect = nodes[name].el.getBoundingClientRect();
908
+ nodes[name].w = rect.width / zoom;
909
+ nodes[name].h = rect.height / zoom;
910
+ }});
911
+ drawRelationships();
912
+ updateMinimap();
913
+ }});
914
+ }}
915
+
916
+ function drawRelationships() {{
917
+ svgRels.innerHTML = '';
918
+
919
+ // Marker definitions
920
+ const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
921
+
922
+ ['one', 'many', 'mm'].forEach((type, idx) => {{
923
+ const colors = ['var(--rel-one)', 'var(--rel-many)', 'var(--rel-mm)'];
924
+ const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
925
+ marker.setAttribute('id', 'arrow-' + type);
926
+ marker.setAttribute('viewBox', '0 0 10 7');
927
+ marker.setAttribute('refX', '10');
928
+ marker.setAttribute('refY', '3.5');
929
+ marker.setAttribute('markerWidth', '8');
930
+ marker.setAttribute('markerHeight', '6');
931
+ marker.setAttribute('orient', 'auto');
932
+ const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
933
+ poly.setAttribute('points', '0 0, 10 3.5, 0 7');
934
+ poly.setAttribute('fill', colors[idx]);
935
+ marker.appendChild(poly);
936
+ defs.appendChild(marker);
937
+ }});
938
+
939
+ svgRels.appendChild(defs);
940
+
941
+ SCHEMA.relationships.forEach(r => {{
942
+ const fromNode = nodes[r.from];
943
+ const toNode = nodes[r.to];
944
+ if (!fromNode || !toNode) return;
945
+ if (fromNode.el.style.display === 'none' || toNode.el.style.display === 'none') return;
946
+
947
+ const x1 = fromNode.x + fromNode.w;
948
+ const y1 = fromNode.y + fromNode.h / 2;
949
+ const x2 = toNode.x;
950
+ const y2 = toNode.y + toNode.h / 2;
951
+
952
+ // Determine better connection points
953
+ let sx, sy, ex, ey;
954
+ const dx = toNode.x - (fromNode.x + fromNode.w / 2);
955
+ const dy = toNode.y - (fromNode.y + fromNode.h / 2);
956
+
957
+ if (Math.abs(dx) > Math.abs(dy)) {{
958
+ // Connect left/right
959
+ if (dx > 0) {{
960
+ sx = fromNode.x + fromNode.w; sy = fromNode.y + fromNode.h / 2;
961
+ ex = toNode.x; ey = toNode.y + toNode.h / 2;
962
+ }} else {{
963
+ sx = fromNode.x; sy = fromNode.y + fromNode.h / 2;
964
+ ex = toNode.x + toNode.w; ey = toNode.y + toNode.h / 2;
965
+ }}
966
+ }} else {{
967
+ // Connect top/bottom
968
+ if (dy > 0) {{
969
+ sx = fromNode.x + fromNode.w / 2; sy = fromNode.y + fromNode.h;
970
+ ex = toNode.x + toNode.w / 2; ey = toNode.y;
971
+ }} else {{
972
+ sx = fromNode.x + fromNode.w / 2; sy = fromNode.y;
973
+ ex = toNode.x + toNode.w / 2; ey = toNode.y + toNode.h;
974
+ }}
975
+ }}
976
+
977
+ const cpDist = Math.min(Math.abs(ex - sx) * 0.5, 120);
978
+ const isHorizontal = Math.abs(dx) > Math.abs(dy);
979
+ let cp1x, cp1y, cp2x, cp2y;
980
+
981
+ if (isHorizontal) {{
982
+ cp1x = sx + (dx > 0 ? cpDist : -cpDist); cp1y = sy;
983
+ cp2x = ex + (dx > 0 ? -cpDist : cpDist); cp2y = ey;
984
+ }} else {{
985
+ cp1x = sx; cp1y = sy + (dy > 0 ? cpDist : -cpDist);
986
+ cp2x = ex; cp2y = ey + (dy > 0 ? -cpDist : cpDist);
987
+ }}
988
+
989
+ const color = relColors[r.type] || 'var(--rel-many)';
990
+ const markerType = r.type === 'one-to-one' ? 'one' : r.type === 'many-to-many' ? 'mm' : 'many';
991
+
992
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
993
+ path.setAttribute('d', `M${{sx}},${{sy}} C${{cp1x}},${{cp1y}} ${{cp2x}},${{cp2y}} ${{ex}},${{ey}}`);
994
+ path.setAttribute('stroke', color);
995
+ path.setAttribute('marker-end', `url(#arrow-${{markerType}})`);
996
+ path.classList.add('rel-path');
997
+ path.dataset.from = r.from;
998
+ path.dataset.to = r.to;
999
+ svgRels.appendChild(path);
1000
+
1001
+ // Label
1002
+ const mx = (sx + ex) / 2;
1003
+ const my = (sy + ey) / 2 - 8;
1004
+ const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
1005
+ label.setAttribute('x', mx);
1006
+ label.setAttribute('y', my);
1007
+ label.setAttribute('text-anchor', 'middle');
1008
+ label.classList.add('rel-label');
1009
+ label.dataset.from = r.from;
1010
+ label.dataset.to = r.to;
1011
+ label.textContent = r.fromCol + ' → ' + r.toCol;
1012
+ svgRels.appendChild(label);
1013
+ }});
1014
+ }}
1015
+
1016
+ function setupPanZoom() {{
1017
+ canvas.addEventListener('mousedown', (e) => {{
1018
+ if (e.button !== 0) return;
1019
+ isPanning = true;
1020
+ panStartX = e.clientX;
1021
+ panStartY = e.clientY;
1022
+ panStartPanX = panX;
1023
+ panStartPanY = panY;
1024
+ }});
1025
+
1026
+ window.addEventListener('mousemove', (e) => {{
1027
+ if (dragging) {{
1028
+ const dx = (e.clientX - dragStartX) / zoom;
1029
+ const dy = (e.clientY - dragStartY) / zoom;
1030
+ nodes[dragging].x = dragNodeX + dx;
1031
+ nodes[dragging].y = dragNodeY + dy;
1032
+ nodes[dragging].el.style.left = nodes[dragging].x + 'px';
1033
+ nodes[dragging].el.style.top = nodes[dragging].y + 'px';
1034
+ drawRelationships();
1035
+ updateMinimap();
1036
+ }} else if (isPanning) {{
1037
+ panX = panStartPanX + (e.clientX - panStartX);
1038
+ panY = panStartPanY + (e.clientY - panStartY);
1039
+ applyTransform();
1040
+ updateMinimap();
1041
+ }}
1042
+ }});
1043
+
1044
+ window.addEventListener('mouseup', () => {{
1045
+ dragging = null;
1046
+ isPanning = false;
1047
+ }});
1048
+
1049
+ canvas.addEventListener('wheel', (e) => {{
1050
+ e.preventDefault();
1051
+ const rect = canvas.getBoundingClientRect();
1052
+ const mx = e.clientX - rect.left;
1053
+ const my = e.clientY - rect.top;
1054
+ const oldZoom = zoom;
1055
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
1056
+ zoom = Math.max(0.1, Math.min(3, zoom * delta));
1057
+
1058
+ panX = mx - (mx - panX) * (zoom / oldZoom);
1059
+ panY = my - (my - panY) * (zoom / oldZoom);
1060
+ applyTransform();
1061
+ updateMinimap();
1062
+ }}, {{ passive: false }});
1063
+
1064
+ canvas.addEventListener('click', (e) => {{
1065
+ if (e.target === canvas || e.target === world) {{
1066
+ if (selectedTable) toggleSelect(selectedTable);
1067
+ }}
1068
+ }});
1069
+
1070
+ applyTransform();
1071
+ }}
1072
+
1073
+ function applyTransform() {{
1074
+ world.style.transform = `translate(${{panX}}px, ${{panY}}px) scale(${{zoom}})`;
1075
+ }}
1076
+
1077
+ function resetView() {{
1078
+ zoom = 1; panX = 60; panY = 80;
1079
+ applyTransform();
1080
+ if (selectedTable) toggleSelect(selectedTable);
1081
+ updateMinimap();
1082
+ }}
1083
+
1084
+ function updateMinimap() {{
1085
+ const mc = document.getElementById('minimap-canvas');
1086
+ const ctx = mc.getContext('2d');
1087
+ mc.width = 180; mc.height = 120;
1088
+ ctx.clearRect(0, 0, 180, 120);
1089
+
1090
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1091
+ Object.values(nodes).forEach(n => {{
1092
+ minX = Math.min(minX, n.x);
1093
+ minY = Math.min(minY, n.y);
1094
+ maxX = Math.max(maxX, n.x + n.w);
1095
+ maxY = Math.max(maxY, n.y + n.h);
1096
+ }});
1097
+
1098
+ const pad = 40;
1099
+ const rangeX = (maxX - minX + pad * 2) || 1;
1100
+ const rangeY = (maxY - minY + pad * 2) || 1;
1101
+ const scale = Math.min(180 / rangeX, 120 / rangeY);
1102
+
1103
+ SCHEMA.relationships.forEach(r => {{
1104
+ const a = nodes[r.from], b = nodes[r.to];
1105
+ if (!a || !b) return;
1106
+ ctx.beginPath();
1107
+ ctx.moveTo((a.x + a.w / 2 - minX + pad) * scale, (a.y + a.h / 2 - minY + pad) * scale);
1108
+ ctx.lineTo((b.x + b.w / 2 - minX + pad) * scale, (b.y + b.h / 2 - minY + pad) * scale);
1109
+ ctx.strokeStyle = 'rgba(108,141,250,0.2)';
1110
+ ctx.stroke();
1111
+ }});
1112
+
1113
+ Object.values(nodes).forEach(n => {{
1114
+ ctx.fillStyle = 'rgba(108,141,250,0.6)';
1115
+ ctx.fillRect(
1116
+ (n.x - minX + pad) * scale,
1117
+ (n.y - minY + pad) * scale,
1118
+ Math.max(n.w * scale, 3),
1119
+ Math.max(n.h * scale, 2)
1120
+ );
1121
+ }});
1122
+ }}
1123
+
1124
+ function escHtml(s) {{
1125
+ const d = document.createElement('div');
1126
+ d.textContent = s;
1127
+ return d.innerHTML;
1128
+ }}
1129
+
1130
+ init();
1131
+ </script>
1132
+ </body>
1133
+ </html>"""
1134
+
1135
+ with open(output_path, "w") as f:
1136
+ f.write(html)
1137
+
1138
+
1139
+ # ---------------------------------------------------------------------------
1140
+ # Main
1141
+ # ---------------------------------------------------------------------------
1142
+
1143
+ def main():
1144
+ parser = argparse.ArgumentParser(description="Database Schema Visualizer — Interactive ERD Generator")
1145
+ group = parser.add_mutually_exclusive_group(required=True)
1146
+ group.add_argument("--prisma", nargs="+", help="Path(s) to Prisma schema file(s)")
1147
+ group.add_argument("--sql", help="Path to directory containing .sql migration files")
1148
+ group.add_argument("--typeorm", help="Path to directory containing TypeORM entity files")
1149
+ parser.add_argument("-o", "--output", default="db-schema.html", help="Output HTML file path")
1150
+ parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
1151
+
1152
+ args = parser.parse_args()
1153
+
1154
+ if args.prisma:
1155
+ for p in args.prisma:
1156
+ if not os.path.isfile(p):
1157
+ print(f"Error: File not found: {p}", file=sys.stderr)
1158
+ sys.exit(1)
1159
+ schema = parse_prisma(args.prisma)
1160
+ print(f"Parsed {len(schema.tables)} models from Prisma schema")
1161
+
1162
+ elif args.sql:
1163
+ if not os.path.isdir(args.sql):
1164
+ print(f"Error: Directory not found: {args.sql}", file=sys.stderr)
1165
+ sys.exit(1)
1166
+ schema = parse_sql(args.sql)
1167
+ print(f"Parsed {len(schema.tables)} tables from SQL migrations")
1168
+
1169
+ elif args.typeorm:
1170
+ if not os.path.isdir(args.typeorm):
1171
+ print(f"Error: Directory not found: {args.typeorm}", file=sys.stderr)
1172
+ sys.exit(1)
1173
+ schema = parse_typeorm(args.typeorm)
1174
+ print(f"Parsed {len(schema.tables)} entities from TypeORM")
1175
+
1176
+ print(f"Found {len(schema.relationships)} relationships")
1177
+
1178
+ generate_html(schema, args.output)
1179
+ print(f"Generated: {args.output}")
1180
+
1181
+ if not args.no_open:
1182
+ abs_path = os.path.abspath(args.output)
1183
+ try:
1184
+ webbrowser.open("file://" + abs_path)
1185
+ print("Opened in browser")
1186
+ except Exception:
1187
+ print(f"Open manually: file://{abs_path}")
1188
+
1189
+
1190
+ if __name__ == "__main__":
1191
+ main()