commiter-cli 0.3.0__py3-none-any.whl

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 (96) hide show
  1. commiter/__init__.py +3 -0
  2. commiter/adapters/__init__.py +0 -0
  3. commiter/adapters/base.py +96 -0
  4. commiter/adapters/django_rest.py +247 -0
  5. commiter/adapters/express.py +204 -0
  6. commiter/adapters/fastapi.py +170 -0
  7. commiter/adapters/flask.py +169 -0
  8. commiter/adapters/nextjs.py +180 -0
  9. commiter/adapters/prisma.py +76 -0
  10. commiter/adapters/raw_sql.py +191 -0
  11. commiter/adapters/react.py +129 -0
  12. commiter/adapters/sqlalchemy.py +99 -0
  13. commiter/adapters/supabase.py +68 -0
  14. commiter/auth.py +130 -0
  15. commiter/cli.py +667 -0
  16. commiter/correlator.py +208 -0
  17. commiter/extractors/__init__.py +0 -0
  18. commiter/extractors/api_calls.py +91 -0
  19. commiter/extractors/api_endpoints.py +354 -0
  20. commiter/extractors/backend_files.py +33 -0
  21. commiter/extractors/base.py +40 -0
  22. commiter/extractors/db_operations.py +69 -0
  23. commiter/extractors/dependencies.py +219 -0
  24. commiter/generic_resolver.py +204 -0
  25. commiter/handler_index.py +97 -0
  26. commiter/lib.py +63 -0
  27. commiter/middleware_index.py +350 -0
  28. commiter/models.py +117 -0
  29. commiter/parser.py +1283 -0
  30. commiter/prefix_index.py +211 -0
  31. commiter/report/__init__.py +0 -0
  32. commiter/report/ai.py +120 -0
  33. commiter/report/api_guide.py +217 -0
  34. commiter/report/architecture.py +930 -0
  35. commiter/report/console.py +254 -0
  36. commiter/report/json_output.py +122 -0
  37. commiter/report/markdown.py +163 -0
  38. commiter/scanner.py +383 -0
  39. commiter/type_index.py +304 -0
  40. commiter/uploader.py +46 -0
  41. commiter/utils/__init__.py +0 -0
  42. commiter/utils/env_reader.py +78 -0
  43. commiter/utils/file_classifier.py +187 -0
  44. commiter/utils/path_helpers.py +73 -0
  45. commiter/utils/tsconfig_resolver.py +281 -0
  46. commiter/wrapper_index.py +288 -0
  47. commiter_cli-0.3.0.dist-info/METADATA +14 -0
  48. commiter_cli-0.3.0.dist-info/RECORD +96 -0
  49. commiter_cli-0.3.0.dist-info/WHEEL +5 -0
  50. commiter_cli-0.3.0.dist-info/entry_points.txt +2 -0
  51. commiter_cli-0.3.0.dist-info/top_level.txt +2 -0
  52. tests/__init__.py +0 -0
  53. tests/fixtures/arch_backend/app.py +22 -0
  54. tests/fixtures/arch_backend/middleware/__init__.py +0 -0
  55. tests/fixtures/arch_backend/middleware/rate_limit.py +4 -0
  56. tests/fixtures/arch_backend/routes/__init__.py +0 -0
  57. tests/fixtures/arch_backend/routes/analytics.py +20 -0
  58. tests/fixtures/arch_backend/routes/auth.py +29 -0
  59. tests/fixtures/arch_backend/routes/projects.py +60 -0
  60. tests/fixtures/arch_backend/routes/users.py +55 -0
  61. tests/fixtures/arch_monorepo/apps/api/app.py +30 -0
  62. tests/fixtures/arch_monorepo/apps/api/middleware/__init__.py +0 -0
  63. tests/fixtures/arch_monorepo/apps/api/middleware/auth.py +17 -0
  64. tests/fixtures/arch_monorepo/apps/api/middleware/rate_limit.py +10 -0
  65. tests/fixtures/arch_monorepo/apps/api/routes/__init__.py +0 -0
  66. tests/fixtures/arch_monorepo/apps/api/routes/auth.py +46 -0
  67. tests/fixtures/arch_monorepo/apps/api/routes/invites.py +30 -0
  68. tests/fixtures/arch_monorepo/apps/api/routes/notifications.py +25 -0
  69. tests/fixtures/arch_monorepo/apps/api/routes/projects.py +80 -0
  70. tests/fixtures/arch_monorepo/apps/api/routes/tasks.py +91 -0
  71. tests/fixtures/arch_monorepo/apps/api/routes/users.py +48 -0
  72. tests/fixtures/arch_monorepo/apps/api/services/__init__.py +0 -0
  73. tests/fixtures/arch_monorepo/apps/api/services/email.py +11 -0
  74. tests/fixtures/backend_b/app.py +17 -0
  75. tests/fixtures/fastapi_app/app.py +48 -0
  76. tests/fixtures/fastapi_crossfile/routes.py +18 -0
  77. tests/fixtures/fastapi_crossfile/schemas.py +21 -0
  78. tests/fixtures/flask_app/app.py +33 -0
  79. tests/fixtures/flask_blueprint/app.py +7 -0
  80. tests/fixtures/flask_blueprint/routes/items.py +13 -0
  81. tests/fixtures/flask_blueprint/routes/users.py +20 -0
  82. tests/fixtures/middleware_test_flask/routes/public.py +8 -0
  83. tests/fixtures/middleware_test_flask/routes/users.py +26 -0
  84. tests/fixtures/python_deep_imports/app/__init__.py +0 -0
  85. tests/fixtures/python_deep_imports/app/api/__init__.py +0 -0
  86. tests/fixtures/python_deep_imports/app/api/health.py +11 -0
  87. tests/fixtures/python_deep_imports/app/api/v1/__init__.py +0 -0
  88. tests/fixtures/python_deep_imports/app/api/v1/items.py +18 -0
  89. tests/fixtures/python_deep_imports/app/api/v1/users.py +27 -0
  90. tests/fixtures/python_deep_imports/app/schemas/__init__.py +0 -0
  91. tests/fixtures/python_deep_imports/app/schemas/item.py +13 -0
  92. tests/fixtures/python_deep_imports/app/schemas/user.py +15 -0
  93. tests/fixtures/python_deep_imports/app/shared/__init__.py +0 -0
  94. tests/fixtures/python_deep_imports/app/shared/models.py +7 -0
  95. tests/fixtures/raw_sql_test/app.py +54 -0
  96. tests/test_architecture.py +757 -0
@@ -0,0 +1,191 @@
1
+ """Raw SQL adapter — detects database operations from raw SQL execution calls.
2
+
3
+ Detects patterns like:
4
+ Python: cursor.execute("SELECT * FROM users"), db.execute(text("INSERT INTO ..."))
5
+ JS/TS: pool.query("SELECT * FROM orders"), knex.raw("DELETE FROM sessions")
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import TYPE_CHECKING
12
+
13
+ from commiter.adapters.base import DBAdapter, DBOpMatch
14
+
15
+ if TYPE_CHECKING:
16
+ from tree_sitter import Tree
17
+
18
+ # Regex for SQL execution method calls across Python and JS
19
+ # Matches: cursor.execute(, db.execute(, pool.query(, client.query(, knex.raw(, etc.
20
+ _SQL_EXEC_RE = re.compile(
21
+ r'(?:cursor|stmt|db|conn|connection|pool|client|session|knex)'
22
+ r'\s*\.\s*'
23
+ r'(?:execute|query|raw|all|run|get|fetchall|fetchone|fetchmany)'
24
+ r'\s*\(',
25
+ re.IGNORECASE,
26
+ )
27
+
28
+ # SQL operation classification
29
+ _SQL_OP_RE = re.compile(
30
+ r'^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|TRUNCATE)',
31
+ re.IGNORECASE,
32
+ )
33
+
34
+ # Table name extraction patterns
35
+ _TABLE_FROM_RE = re.compile(r'\bFROM\s+[`"\']?(\w+)[`"\']?', re.IGNORECASE)
36
+ _TABLE_INTO_RE = re.compile(r'\bINTO\s+[`"\']?(\w+)[`"\']?', re.IGNORECASE)
37
+ _TABLE_UPDATE_RE = re.compile(r'\bUPDATE\s+[`"\']?(\w+)[`"\']?', re.IGNORECASE)
38
+ _TABLE_JOIN_RE = re.compile(r'\bJOIN\s+[`"\']?(\w+)[`"\']?', re.IGNORECASE)
39
+ _TABLE_TRUNCATE_RE = re.compile(r'\bTRUNCATE\s+(?:TABLE\s+)?[`"\']?(\w+)[`"\']?', re.IGNORECASE)
40
+
41
+
42
+ class RawSQLAdapter(DBAdapter):
43
+ """Detects database operations from raw SQL strings in execute/query calls."""
44
+
45
+ orm_name = "raw_sql"
46
+ language = "python" # works for both Python and JS via regex on source text
47
+
48
+ def find_db_operations(self, tree: "Tree", source: bytes) -> list[DBOpMatch]:
49
+ ops = []
50
+ text = source.decode("utf-8", errors="replace")
51
+
52
+ for match in _SQL_EXEC_RE.finditer(text):
53
+ line = text[:match.start()].count("\n") + 1
54
+
55
+ # Extract the SQL string from the first argument
56
+ sql = self._extract_sql_string(text, match.end())
57
+ if not sql:
58
+ continue
59
+
60
+ # Classify the operation
61
+ op_type = self._classify_sql(sql)
62
+ if not op_type:
63
+ continue
64
+
65
+ # Extract table names
66
+ tables = self._extract_table_names(sql)
67
+ if not tables:
68
+ # We found SQL but couldn't parse a table name
69
+ ops.append(DBOpMatch(
70
+ operation_type=op_type,
71
+ table_name="<dynamic>",
72
+ call_node=tree.root_node,
73
+ line=line,
74
+ ))
75
+ continue
76
+
77
+ # Create an operation for each table found
78
+ for table in tables:
79
+ filters = self._extract_where_fields(sql)
80
+ ops.append(DBOpMatch(
81
+ operation_type=op_type,
82
+ table_name=table,
83
+ call_node=tree.root_node,
84
+ line=line,
85
+ filters=filters,
86
+ ))
87
+
88
+ return ops
89
+
90
+ def _extract_sql_string(self, text: str, start: int) -> str | None:
91
+ """Extract the SQL string from the first argument after an opening paren.
92
+
93
+ Handles:
94
+ execute("SELECT ...")
95
+ execute(text("SELECT ..."))
96
+ execute(`SELECT ...`)
97
+ query('SELECT ...')
98
+ """
99
+ remaining = text[start:start + 1000] # look ahead
100
+
101
+ # Skip whitespace
102
+ remaining = remaining.lstrip()
103
+
104
+ # Handle text() wrapper: execute(text("SELECT ..."))
105
+ if remaining.startswith("text("):
106
+ remaining = remaining[5:].lstrip()
107
+
108
+ # Find the string delimiter
109
+ if not remaining:
110
+ return None
111
+
112
+ delimiter = None
113
+ if remaining[0] in ("'", '"', "`"):
114
+ delimiter = remaining[0]
115
+ elif remaining[0] == 'f' and len(remaining) > 1 and remaining[1] in ("'", '"'):
116
+ # Python f-string: f"SELECT ..."
117
+ delimiter = remaining[1]
118
+ remaining = remaining[1:]
119
+ else:
120
+ return None
121
+
122
+ # Handle triple quotes
123
+ if remaining[:3] in ('"""', "'''"):
124
+ end_delim = remaining[:3]
125
+ content_start = 3
126
+ end_idx = remaining.find(end_delim, content_start)
127
+ if end_idx != -1:
128
+ return remaining[content_start:end_idx].strip()
129
+ return None
130
+
131
+ # Single delimiter
132
+ content_start = 1
133
+ i = content_start
134
+ while i < len(remaining):
135
+ if remaining[i] == "\\" :
136
+ i += 2 # skip escaped char
137
+ continue
138
+ if remaining[i] == delimiter:
139
+ return remaining[content_start:i].strip()
140
+ i += 1
141
+
142
+ return None
143
+
144
+ def _classify_sql(self, sql: str) -> str | None:
145
+ """Classify SQL statement type."""
146
+ match = _SQL_OP_RE.match(sql)
147
+ if not match:
148
+ return None
149
+
150
+ op = match.group(1).upper()
151
+ if op == "SELECT":
152
+ return "select"
153
+ elif op == "INSERT":
154
+ return "insert"
155
+ elif op == "UPDATE":
156
+ return "update"
157
+ elif op in ("DELETE", "TRUNCATE"):
158
+ return "delete"
159
+ elif op in ("CREATE", "ALTER", "DROP"):
160
+ return "ddl"
161
+ return "query"
162
+
163
+ def _extract_table_names(self, sql: str) -> list[str]:
164
+ """Extract table names from a SQL statement."""
165
+ tables = []
166
+ seen = set()
167
+
168
+ for pattern in (_TABLE_FROM_RE, _TABLE_INTO_RE, _TABLE_UPDATE_RE, _TABLE_JOIN_RE, _TABLE_TRUNCATE_RE):
169
+ for match in pattern.finditer(sql):
170
+ table = match.group(1)
171
+ # Filter out SQL keywords that might match
172
+ if table.upper() not in ("SELECT", "WHERE", "SET", "VALUES", "AND", "OR",
173
+ "NOT", "NULL", "TABLE", "INDEX", "VIEW", "AS",
174
+ "ON", "IN", "EXISTS", "LIKE", "BETWEEN"):
175
+ if table not in seen:
176
+ seen.add(table)
177
+ tables.append(table)
178
+
179
+ return tables
180
+
181
+ def _extract_where_fields(self, sql: str) -> list[str]:
182
+ """Extract column names from WHERE clause."""
183
+ filters = []
184
+ where_match = re.search(r'\bWHERE\b(.+?)(?:ORDER|GROUP|LIMIT|HAVING|$)', sql, re.IGNORECASE)
185
+ if where_match:
186
+ where_clause = where_match.group(1)
187
+ for col_match in re.finditer(r'(\w+)\s*(?:=|!=|<>|>|<|>=|<=|LIKE|IN|IS)\s', where_clause, re.IGNORECASE):
188
+ col = col_match.group(1)
189
+ if col.upper() not in ("AND", "OR", "NOT"):
190
+ filters.append(col)
191
+ return filters
@@ -0,0 +1,129 @@
1
+ """React frontend adapter — detects fetch/axios API calls in components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from tree_sitter import Node
10
+
11
+ from commiter.adapters.base import FrontendAdapter, APICallMatch
12
+ from commiter.parser import node_text, find_nodes_by_type, resolve_url_from_node
13
+
14
+ if TYPE_CHECKING:
15
+ from tree_sitter import Tree
16
+
17
+ # HTTP methods for axios
18
+ AXIOS_METHODS = {"get", "post", "put", "delete", "patch", "request"}
19
+
20
+
21
+ class ReactAdapter(FrontendAdapter):
22
+ framework_name = "react"
23
+ language = "javascript" # handles JS/TS/JSX/TSX
24
+
25
+ def find_api_calls(self, tree: Tree, source: bytes, file_path: str = "",
26
+ constants: dict[str, str] | None = None) -> list[APICallMatch]:
27
+ calls: list[APICallMatch] = []
28
+ calls.extend(self._find_fetch_calls(tree.root_node, source, constants))
29
+ calls.extend(self._find_axios_calls(tree.root_node, source, constants))
30
+ return calls
31
+
32
+ def _find_fetch_calls(self, root_node: Node, source: bytes,
33
+ constants: dict[str, str] | None = None) -> list[APICallMatch]:
34
+ """Find fetch() calls with static, template, and concatenated URLs."""
35
+ results = []
36
+ call_nodes = find_nodes_by_type(root_node, "call_expression")
37
+
38
+ for call in call_nodes:
39
+ func = call.child_by_field_name("function")
40
+ if not func:
41
+ continue
42
+
43
+ func_text = node_text(func, source)
44
+ if func_text != "fetch":
45
+ continue
46
+
47
+ args = call.child_by_field_name("arguments")
48
+ if not args:
49
+ continue
50
+
51
+ url = None
52
+ method = "GET"
53
+
54
+ for arg in args.children:
55
+ if arg.type in ("string", "template_string", "binary_expression", "identifier", "member_expression"):
56
+ if url is None: # first matching arg is the URL
57
+ url = resolve_url_from_node(arg, source, constants)
58
+ elif arg.type == "object":
59
+ obj_text = node_text(arg, source)
60
+ method_match = re.search(r'method\s*:\s*["\'](\w+)["\']', obj_text)
61
+ if method_match:
62
+ method = method_match.group(1).upper()
63
+
64
+ if url:
65
+ results.append(APICallMatch(
66
+ http_method=method,
67
+ url_pattern=url,
68
+ call_node=call,
69
+ line=call.start_point[0] + 1,
70
+ ))
71
+
72
+ return results
73
+
74
+ def _find_axios_calls(self, root_node: Node, source: bytes,
75
+ constants: dict[str, str] | None = None) -> list[APICallMatch]:
76
+ """Find axios.get(), axios.post(), etc. with dynamic URL resolution."""
77
+ results = []
78
+ call_nodes = find_nodes_by_type(root_node, "call_expression")
79
+
80
+ for call in call_nodes:
81
+ func = call.child_by_field_name("function")
82
+ if not func or func.type != "member_expression":
83
+ continue
84
+
85
+ func_text = node_text(func, source)
86
+
87
+ match = re.match(r"(\w+)\.(get|post|put|delete|patch|request)", func_text)
88
+ if not match:
89
+ continue
90
+
91
+ obj_name = match.group(1)
92
+ if obj_name not in ("axios", "api", "client", "http", "axiosInstance", "apiClient"):
93
+ continue
94
+
95
+ method = match.group(2).upper()
96
+ if method == "REQUEST":
97
+ method = "GET"
98
+
99
+ args = call.child_by_field_name("arguments")
100
+ if not args:
101
+ continue
102
+
103
+ url = None
104
+ for arg in args.children:
105
+ if arg.type in ("string", "template_string", "binary_expression", "identifier", "member_expression"):
106
+ url = resolve_url_from_node(arg, source, constants)
107
+ if url:
108
+ break
109
+
110
+ if url:
111
+ response_type = self._extract_generic_type(call, source)
112
+ results.append(APICallMatch(
113
+ http_method=method,
114
+ url_pattern=url,
115
+ call_node=call,
116
+ line=call.start_point[0] + 1,
117
+ response_type=response_type,
118
+ ))
119
+
120
+ return results
121
+
122
+ def _extract_generic_type(self, call_node: Node, source: bytes) -> str | None:
123
+ """Extract generic type parameter from calls like axios.post<UserResponse>(...)."""
124
+ for child in call_node.children:
125
+ if child.type == "type_arguments":
126
+ for sub in child.children:
127
+ if sub.type not in ("<", ">", ","):
128
+ return node_text(sub, source).strip()
129
+ return None
@@ -0,0 +1,99 @@
1
+ """SQLAlchemy adapter — detects SQLAlchemy ORM database operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from commiter.adapters.base import DBAdapter, DBOpMatch
9
+
10
+ if TYPE_CHECKING:
11
+ from tree_sitter import Tree
12
+
13
+ # SQLAlchemy session methods to operation types
14
+ SA_OP_MAP = {
15
+ "query": "select",
16
+ "add": "insert",
17
+ "add_all": "insert",
18
+ "merge": "upsert",
19
+ "delete": "delete",
20
+ "execute": "query",
21
+ }
22
+
23
+
24
+ class SQLAlchemyAdapter(DBAdapter):
25
+ orm_name = "sqlalchemy"
26
+ language = "python"
27
+
28
+ def find_db_operations(self, tree: Tree, source: bytes) -> list[DBOpMatch]:
29
+ ops = []
30
+ text = source.decode("utf-8", errors="replace")
31
+
32
+ # Pattern 1: session.query(Model).filter_by(...) / session.query(Model).filter(...)
33
+ for match in re.finditer(
34
+ r'(?:session|db)\s*\.\s*query\s*\(\s*(\w+)\s*\)',
35
+ text,
36
+ ):
37
+ model_name = match.group(1)
38
+ line = text[:match.start()].count("\n") + 1
39
+ filters = self._extract_sa_filters(text, match.end())
40
+
41
+ ops.append(DBOpMatch(
42
+ operation_type="select",
43
+ table_name=model_name,
44
+ call_node=tree.root_node,
45
+ line=line,
46
+ filters=filters,
47
+ ))
48
+
49
+ # Pattern 2: session.add(instance), session.delete(instance)
50
+ for match in re.finditer(
51
+ r'(?:session|db)\s*\.\s*(add|add_all|delete|merge)\s*\(',
52
+ text,
53
+ ):
54
+ method = match.group(1)
55
+ op_type = SA_OP_MAP.get(method, "unknown")
56
+ line = text[:match.start()].count("\n") + 1
57
+
58
+ ops.append(DBOpMatch(
59
+ operation_type=op_type,
60
+ table_name="<from_instance>",
61
+ call_node=tree.root_node,
62
+ line=line,
63
+ ))
64
+
65
+ # Pattern 3: Model.query.filter_by(...) (Flask-SQLAlchemy style)
66
+ for match in re.finditer(
67
+ r'(\b[A-Z]\w+)\s*\.\s*query\s*\.',
68
+ text,
69
+ ):
70
+ model_name = match.group(1)
71
+ line = text[:match.start()].count("\n") + 1
72
+ filters = self._extract_sa_filters(text, match.end())
73
+
74
+ ops.append(DBOpMatch(
75
+ operation_type="select",
76
+ table_name=model_name,
77
+ call_node=tree.root_node,
78
+ line=line,
79
+ filters=filters,
80
+ ))
81
+
82
+ return ops
83
+
84
+ def _extract_sa_filters(self, text: str, start: int) -> list[str]:
85
+ """Extract filter field names from .filter_by(field=...) or .filter(Model.field == ...)."""
86
+ filters = []
87
+ remaining = text[start:start + 500]
88
+
89
+ # filter_by(field=value)
90
+ fb_match = re.search(r'\.filter_by\s*\(([^)]+)\)', remaining)
91
+ if fb_match:
92
+ for field in re.finditer(r'(\w+)\s*=', fb_match.group(1)):
93
+ filters.append(field.group(1))
94
+
95
+ # filter(Model.field == value)
96
+ for match in re.finditer(r'\.filter\s*\([^)]*?\.(\w+)\s*[=!<>]', remaining):
97
+ filters.append(match.group(1))
98
+
99
+ return filters
@@ -0,0 +1,68 @@
1
+ """Supabase adapter — detects Supabase client DB operations (Python + JS)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from commiter.adapters.base import DBAdapter, DBOpMatch
9
+ from commiter.parser import node_text, find_nodes_by_type
10
+
11
+ if TYPE_CHECKING:
12
+ from tree_sitter import Tree
13
+
14
+ # Operation method names in Supabase client chain
15
+ OPERATION_MAP = {
16
+ "select": "select",
17
+ "insert": "insert",
18
+ "update": "update",
19
+ "delete": "delete",
20
+ "upsert": "upsert",
21
+ }
22
+
23
+
24
+ class SupabaseAdapter(DBAdapter):
25
+ orm_name = "supabase"
26
+ language = "python" # works for both Python and JS
27
+
28
+ def find_db_operations(self, tree: Tree, source: bytes) -> list[DBOpMatch]:
29
+ ops = []
30
+ call_nodes = find_nodes_by_type(tree.root_node, "call_expression" if b"supabase" in source else "call")
31
+
32
+ # Use regex on source for reliability across both Python and JS ASTs
33
+ text = source.decode("utf-8", errors="replace")
34
+
35
+ # Match patterns like:
36
+ # supabase.table("users").select(...)
37
+ # supabase.from("users").select(...)
38
+ # supabase.from_("users").select(...) (Python client)
39
+ for match in re.finditer(
40
+ r'(?:supabase|db|client)\s*\.\s*(?:table|from_?)\s*\(\s*["\'](\w+)["\']\s*\)\s*\.\s*(\w+)',
41
+ text,
42
+ ):
43
+ table_name = match.group(1)
44
+ method = match.group(2)
45
+ op_type = OPERATION_MAP.get(method)
46
+ if not op_type:
47
+ continue
48
+
49
+ line = text[:match.start()].count("\n") + 1
50
+ filters = self._extract_filters(text, match.end())
51
+
52
+ ops.append(DBOpMatch(
53
+ operation_type=op_type,
54
+ table_name=table_name,
55
+ call_node=tree.root_node, # placeholder
56
+ line=line,
57
+ filters=filters,
58
+ ))
59
+
60
+ return ops
61
+
62
+ def _extract_filters(self, text: str, start: int) -> list[str]:
63
+ """Extract .eq(), .neq(), .gt() etc. filter calls after the operation."""
64
+ filters = []
65
+ remaining = text[start:start + 500] # look ahead
66
+ for match in re.finditer(r'\.\s*(eq|neq|gt|gte|lt|lte|in_|like|ilike|is_|contains)\s*\(\s*["\'](\w+)["\']', remaining):
67
+ filters.append(f"{match.group(1)}({match.group(2)})")
68
+ return filters
commiter/auth.py ADDED
@@ -0,0 +1,130 @@
1
+ """Browser-based CLI authentication for Commiter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ import threading
8
+ import webbrowser
9
+ from http.server import HTTPServer, BaseHTTPRequestHandler
10
+ from pathlib import Path
11
+ from urllib.parse import urlparse, parse_qs
12
+
13
+ FRONTEND_URL = "https://commiter-three.vercel.app"
14
+ CREDENTIALS_DIR = Path.home() / ".config" / "commiter"
15
+ CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials.json"
16
+
17
+
18
+ def _find_free_port() -> int:
19
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
20
+ s.bind(("127.0.0.1", 0))
21
+ return s.getsockname()[1]
22
+
23
+
24
+ def login() -> bool:
25
+ """Run the browser-based login flow.
26
+
27
+ Opens the browser to the Commiter consent page. A temporary local
28
+ HTTP server receives the callback with the CLI token.
29
+
30
+ Returns True on success, False on failure/timeout.
31
+ """
32
+ # Check if already logged in
33
+ existing = get_saved_token()
34
+ if existing:
35
+ creds = _read_credentials()
36
+ email = creds.get("email", "unknown")
37
+ print(f"Already logged in as {email}")
38
+ print("Run commiter --logout first to switch accounts.")
39
+ return True
40
+
41
+ port = _find_free_port()
42
+ result: dict = {}
43
+
44
+ class CallbackHandler(BaseHTTPRequestHandler):
45
+ def do_GET(self):
46
+ parsed = urlparse(self.path)
47
+ if parsed.path == "/callback":
48
+ params = parse_qs(parsed.query)
49
+ token = params.get("token", [None])[0]
50
+ email = params.get("email", [""])[0]
51
+ if token:
52
+ result["token"] = token
53
+ result["email"] = email
54
+ self.send_response(200)
55
+ self.send_header("Content-Type", "text/html")
56
+ self.end_headers()
57
+ self.wfile.write(
58
+ b"<html><body><h2>Login successful!</h2>"
59
+ b"<p>You can close this window and return to your terminal.</p>"
60
+ b"</body></html>"
61
+ )
62
+ else:
63
+ self.send_response(400)
64
+ self.send_header("Content-Type", "text/html")
65
+ self.end_headers()
66
+ self.wfile.write(b"<html><body><h2>Login failed</h2><p>No token received.</p></body></html>")
67
+ else:
68
+ self.send_response(404)
69
+ self.end_headers()
70
+
71
+ def log_message(self, format, *args):
72
+ pass # Suppress request logs
73
+
74
+ server = HTTPServer(("127.0.0.1", port), CallbackHandler)
75
+ server.timeout = 120 # 2 minute timeout
76
+
77
+ authorize_url = f"{FRONTEND_URL}/cli/authorize?port={port}"
78
+ print(f"Opening browser to log in...")
79
+ print(f"If the browser doesn't open, visit: {authorize_url}")
80
+ webbrowser.open(authorize_url)
81
+
82
+ # Wait for the callback
83
+ def serve():
84
+ while not result and server.timeout > 0:
85
+ server.handle_request()
86
+
87
+ thread = threading.Thread(target=serve, daemon=True)
88
+ thread.start()
89
+ thread.join(timeout=120)
90
+ server.server_close()
91
+
92
+ if result.get("token"):
93
+ _save_credentials(result["token"], result.get("email", ""))
94
+ print(f"Logged in as {result.get('email', 'unknown')}")
95
+ return True
96
+ else:
97
+ print("Login timed out or was denied.")
98
+ return False
99
+
100
+
101
+ def logout() -> None:
102
+ """Remove saved credentials."""
103
+ if CREDENTIALS_FILE.exists():
104
+ CREDENTIALS_FILE.unlink()
105
+ print("Logged out successfully.")
106
+ else:
107
+ print("Not currently logged in.")
108
+
109
+
110
+ def get_saved_token() -> str | None:
111
+ """Read the saved CLI token. Returns None if missing or expired."""
112
+ creds = _read_credentials()
113
+ return creds.get("token") if creds else None
114
+
115
+
116
+ def _read_credentials() -> dict | None:
117
+ if not CREDENTIALS_FILE.exists():
118
+ return None
119
+ try:
120
+ return json.loads(CREDENTIALS_FILE.read_text())
121
+ except (json.JSONDecodeError, OSError):
122
+ return None
123
+
124
+
125
+ def _save_credentials(token: str, email: str) -> None:
126
+ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
127
+ CREDENTIALS_FILE.write_text(json.dumps({
128
+ "token": token,
129
+ "email": email,
130
+ }, indent=2))