nogic 0.0.1__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.
- nogic/__init__.py +3 -0
- nogic/api/__init__.py +23 -0
- nogic/api/client.py +390 -0
- nogic/commands/__init__.py +1 -0
- nogic/commands/init.py +125 -0
- nogic/commands/login.py +75 -0
- nogic/commands/projects.py +138 -0
- nogic/commands/reindex.py +117 -0
- nogic/commands/status.py +165 -0
- nogic/commands/sync.py +72 -0
- nogic/commands/telemetry_cmd.py +65 -0
- nogic/commands/watch.py +167 -0
- nogic/config.py +157 -0
- nogic/ignore.py +109 -0
- nogic/main.py +58 -0
- nogic/parsing/__init__.py +22 -0
- nogic/parsing/js_extractor.py +674 -0
- nogic/parsing/parser.py +220 -0
- nogic/parsing/python_extractor.py +484 -0
- nogic/parsing/types.py +80 -0
- nogic/storage/__init__.py +14 -0
- nogic/storage/relationships.py +322 -0
- nogic/storage/schema.py +154 -0
- nogic/storage/symbols.py +203 -0
- nogic/telemetry.py +142 -0
- nogic/ui.py +60 -0
- nogic/watcher/__init__.py +7 -0
- nogic/watcher/monitor.py +80 -0
- nogic/watcher/storage.py +185 -0
- nogic/watcher/sync.py +879 -0
- nogic-0.0.1.dist-info/METADATA +201 -0
- nogic-0.0.1.dist-info/RECORD +35 -0
- nogic-0.0.1.dist-info/WHEEL +4 -0
- nogic-0.0.1.dist-info/entry_points.txt +2 -0
- nogic-0.0.1.dist-info/licenses/LICENSE +21 -0
nogic/__init__.py
ADDED
nogic/api/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""API client module."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
IndexProgress,
|
|
5
|
+
IndexResult,
|
|
6
|
+
GraphWipeResult,
|
|
7
|
+
Project,
|
|
8
|
+
NogicClient,
|
|
9
|
+
UploadResult,
|
|
10
|
+
StagingStats,
|
|
11
|
+
get_directory_hash,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"IndexProgress",
|
|
16
|
+
"IndexResult",
|
|
17
|
+
"GraphWipeResult",
|
|
18
|
+
"Project",
|
|
19
|
+
"NogicClient",
|
|
20
|
+
"UploadResult",
|
|
21
|
+
"StagingStats",
|
|
22
|
+
"get_directory_hash",
|
|
23
|
+
]
|
nogic/api/client.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""HTTP client for Nogic backend API."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Iterator, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from nogic.config import Config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_directory_hash(directory: str = None) -> str:
|
|
15
|
+
"""Generate SHA256 hash of absolute directory path."""
|
|
16
|
+
if directory is None:
|
|
17
|
+
directory = os.getcwd()
|
|
18
|
+
abs_path = os.path.abspath(directory)
|
|
19
|
+
return hashlib.sha256(abs_path.encode()).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class IndexProgress:
|
|
24
|
+
stage: str
|
|
25
|
+
message: str
|
|
26
|
+
current: int
|
|
27
|
+
total: int
|
|
28
|
+
files_indexed: Optional[int] = None
|
|
29
|
+
files_skipped: Optional[int] = None
|
|
30
|
+
nodes_created: Optional[int] = None
|
|
31
|
+
edges_created: Optional[int] = None
|
|
32
|
+
errors: Optional[list[dict]] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class IndexResult:
|
|
37
|
+
status: str
|
|
38
|
+
files_indexed: int
|
|
39
|
+
files_skipped: int
|
|
40
|
+
nodes_created: int
|
|
41
|
+
edges_created: int
|
|
42
|
+
errors: list[dict]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class UploadResult:
|
|
47
|
+
"""Result from /v1/index/upload endpoint."""
|
|
48
|
+
files_parsed: int
|
|
49
|
+
total_staged: int
|
|
50
|
+
functions_found: int
|
|
51
|
+
classes_found: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class StagingStats:
|
|
56
|
+
"""Stats from /v1/index/staging/{project_id} endpoint."""
|
|
57
|
+
files: int
|
|
58
|
+
functions: int
|
|
59
|
+
classes: int
|
|
60
|
+
methods: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class GraphWipeResult:
|
|
65
|
+
message: str
|
|
66
|
+
nodes_deleted: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Project:
|
|
71
|
+
id: str
|
|
72
|
+
name: str
|
|
73
|
+
directory_hash: Optional[str] = None
|
|
74
|
+
owner_id: Optional[str] = None
|
|
75
|
+
created_at: Optional[str] = None
|
|
76
|
+
updated_at: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class NogicClient:
|
|
80
|
+
def __init__(self, config: Config):
|
|
81
|
+
self.config = config
|
|
82
|
+
self.base_url = config.api_url.rstrip("/")
|
|
83
|
+
self._client: Optional[httpx.Client] = None
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def client(self) -> httpx.Client:
|
|
87
|
+
if self._client is None:
|
|
88
|
+
self._client = httpx.Client(
|
|
89
|
+
base_url=self.base_url,
|
|
90
|
+
timeout=60.0,
|
|
91
|
+
headers=self._headers(),
|
|
92
|
+
)
|
|
93
|
+
return self._client
|
|
94
|
+
|
|
95
|
+
def _headers(self) -> dict:
|
|
96
|
+
headers = {"Content-Type": "application/json"}
|
|
97
|
+
if self.config.api_key:
|
|
98
|
+
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
|
99
|
+
return headers
|
|
100
|
+
|
|
101
|
+
def close(self):
|
|
102
|
+
if self._client:
|
|
103
|
+
self._client.close()
|
|
104
|
+
self._client = None
|
|
105
|
+
|
|
106
|
+
def verify_key(self) -> dict:
|
|
107
|
+
"""Verify API key. Returns user info or raises."""
|
|
108
|
+
headers = {}
|
|
109
|
+
if self.config.api_key:
|
|
110
|
+
headers["X-API-Key"] = self.config.api_key
|
|
111
|
+
resp = self.client.post("/v1/keys/verify", headers=headers)
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
return resp.json()
|
|
114
|
+
|
|
115
|
+
def list_projects(self) -> list[Project]:
|
|
116
|
+
"""List user's projects."""
|
|
117
|
+
resp = self.client.get("/v1/projects")
|
|
118
|
+
resp.raise_for_status()
|
|
119
|
+
data = resp.json()
|
|
120
|
+
return [
|
|
121
|
+
Project(
|
|
122
|
+
id=p["id"],
|
|
123
|
+
name=p["name"],
|
|
124
|
+
directory_hash=p.get("directory_hash"),
|
|
125
|
+
created_at=p.get("created_at"),
|
|
126
|
+
updated_at=p.get("updated_at"),
|
|
127
|
+
)
|
|
128
|
+
for p in data["projects"]
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
def get_project_by_directory(self, directory_hash: str) -> Optional[Project]:
|
|
132
|
+
"""
|
|
133
|
+
Lookup project by directory hash.
|
|
134
|
+
Returns None if no project found (404).
|
|
135
|
+
"""
|
|
136
|
+
resp = self.client.get(
|
|
137
|
+
"/v1/projects/by-directory",
|
|
138
|
+
params={"hash": directory_hash},
|
|
139
|
+
)
|
|
140
|
+
if resp.status_code == 404:
|
|
141
|
+
return None
|
|
142
|
+
resp.raise_for_status()
|
|
143
|
+
data = resp.json()
|
|
144
|
+
return Project(
|
|
145
|
+
id=data["id"],
|
|
146
|
+
name=data["name"],
|
|
147
|
+
directory_hash=data.get("directory_hash"),
|
|
148
|
+
owner_id=data.get("owner_id"),
|
|
149
|
+
created_at=data.get("created_at"),
|
|
150
|
+
updated_at=data.get("updated_at"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def create_project(
|
|
154
|
+
self, name: str, directory_hash: Optional[str] = None
|
|
155
|
+
) -> Project:
|
|
156
|
+
"""
|
|
157
|
+
Create a new project.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
name: Project name
|
|
161
|
+
directory_hash: SHA256 hash of absolute directory path
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Project object
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
httpx.HTTPStatusError: 409 if project already exists for directory
|
|
168
|
+
"""
|
|
169
|
+
payload = {"name": name}
|
|
170
|
+
if directory_hash:
|
|
171
|
+
payload["directory_hash"] = directory_hash
|
|
172
|
+
|
|
173
|
+
resp = self.client.post("/v1/projects", json=payload)
|
|
174
|
+
resp.raise_for_status()
|
|
175
|
+
data = resp.json()
|
|
176
|
+
return Project(
|
|
177
|
+
id=data.get("project_id") or data.get("id"),
|
|
178
|
+
name=data["name"],
|
|
179
|
+
directory_hash=data.get("directory_hash"),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def wipe_project_graph(self, project_id: str) -> GraphWipeResult:
|
|
183
|
+
"""
|
|
184
|
+
Wipe all graph data for a project (for reindexing).
|
|
185
|
+
|
|
186
|
+
Deletes all nodes/edges in Neo4j but keeps project record in PostgreSQL.
|
|
187
|
+
Uses a longer timeout since batched deletion of large graphs can take time.
|
|
188
|
+
"""
|
|
189
|
+
resp = self.client.delete(
|
|
190
|
+
f"/v1/projects/{project_id}/graph",
|
|
191
|
+
timeout=300.0,
|
|
192
|
+
)
|
|
193
|
+
resp.raise_for_status()
|
|
194
|
+
data = resp.json()
|
|
195
|
+
return GraphWipeResult(
|
|
196
|
+
message=data["message"],
|
|
197
|
+
nodes_deleted=data["nodes_deleted"],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def wipe_project_graph_stream(self, project_id: str) -> Iterator[IndexProgress]:
|
|
201
|
+
"""
|
|
202
|
+
Wipe graph data with SSE streaming progress.
|
|
203
|
+
|
|
204
|
+
Yields IndexProgress events with stage "deleting" (current/total)
|
|
205
|
+
and "complete" when done.
|
|
206
|
+
Falls back to non-streaming wipe if server returns 404.
|
|
207
|
+
"""
|
|
208
|
+
url = f"{self.base_url}/v1/projects/{project_id}/graph/stream"
|
|
209
|
+
|
|
210
|
+
with httpx.stream(
|
|
211
|
+
"DELETE",
|
|
212
|
+
url,
|
|
213
|
+
headers={**self._headers(), "Accept": "text/event-stream"},
|
|
214
|
+
timeout=300.0,
|
|
215
|
+
) as response:
|
|
216
|
+
if response.status_code == 404:
|
|
217
|
+
# Server doesn't support streaming wipe, fall back
|
|
218
|
+
result = self.wipe_project_graph(project_id)
|
|
219
|
+
yield IndexProgress(
|
|
220
|
+
stage="complete",
|
|
221
|
+
message=result.message,
|
|
222
|
+
current=result.nodes_deleted,
|
|
223
|
+
total=result.nodes_deleted,
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
response.raise_for_status()
|
|
227
|
+
for line in response.iter_lines():
|
|
228
|
+
if line.startswith("data: "):
|
|
229
|
+
data = json.loads(line[6:].strip())
|
|
230
|
+
yield IndexProgress(
|
|
231
|
+
stage=data["stage"],
|
|
232
|
+
message=data["message"],
|
|
233
|
+
current=data["current"],
|
|
234
|
+
total=data["total"],
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def delete_files(self, project_id: str, paths: list[str]) -> int:
|
|
238
|
+
"""Delete file nodes from the graph. Returns number of nodes deleted."""
|
|
239
|
+
resp = self.client.post(
|
|
240
|
+
"/v1/index/delete-files",
|
|
241
|
+
json={"project_id": project_id, "paths": paths},
|
|
242
|
+
)
|
|
243
|
+
resp.raise_for_status()
|
|
244
|
+
return resp.json().get("deleted_nodes", 0)
|
|
245
|
+
|
|
246
|
+
def index_files(self, project_id: str, files: list[dict]) -> IndexResult:
|
|
247
|
+
"""
|
|
248
|
+
Index raw code files (synchronous endpoint).
|
|
249
|
+
|
|
250
|
+
files: [{"path": str, "content": str, "language": str}]
|
|
251
|
+
"""
|
|
252
|
+
resp = self.client.post(
|
|
253
|
+
"/v1/index",
|
|
254
|
+
json={"project_id": project_id, "files": files},
|
|
255
|
+
)
|
|
256
|
+
resp.raise_for_status()
|
|
257
|
+
data = resp.json()
|
|
258
|
+
return IndexResult(
|
|
259
|
+
status=data["status"],
|
|
260
|
+
files_indexed=data["files_indexed"],
|
|
261
|
+
files_skipped=data.get("files_skipped", 0),
|
|
262
|
+
nodes_created=data["nodes_created"],
|
|
263
|
+
edges_created=data["edges_created"],
|
|
264
|
+
errors=data.get("errors", []),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def index_files_stream(
|
|
268
|
+
self, project_id: str, files: list[dict]
|
|
269
|
+
) -> Iterator[IndexProgress]:
|
|
270
|
+
"""
|
|
271
|
+
Index files with SSE streaming progress updates.
|
|
272
|
+
|
|
273
|
+
files: [{"path": str, "content": str, "language": str}]
|
|
274
|
+
Yields IndexProgress events as they arrive.
|
|
275
|
+
"""
|
|
276
|
+
url = f"{self.base_url}/v1/index/stream"
|
|
277
|
+
payload = {"project_id": project_id, "files": files}
|
|
278
|
+
|
|
279
|
+
with httpx.stream(
|
|
280
|
+
"POST",
|
|
281
|
+
url,
|
|
282
|
+
json=payload,
|
|
283
|
+
headers={**self._headers(), "Accept": "text/event-stream"},
|
|
284
|
+
timeout=300.0,
|
|
285
|
+
) as response:
|
|
286
|
+
response.raise_for_status()
|
|
287
|
+
for line in response.iter_lines():
|
|
288
|
+
if line.startswith("data: "):
|
|
289
|
+
data = json.loads(line[6:])
|
|
290
|
+
yield IndexProgress(
|
|
291
|
+
stage=data["stage"],
|
|
292
|
+
message=data["message"],
|
|
293
|
+
current=data["current"],
|
|
294
|
+
total=data["total"],
|
|
295
|
+
files_indexed=data.get("files_indexed"),
|
|
296
|
+
files_skipped=data.get("files_skipped"),
|
|
297
|
+
nodes_created=data.get("nodes_created"),
|
|
298
|
+
edges_created=data.get("edges_created"),
|
|
299
|
+
errors=data.get("errors"),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# -------------------------------------------------------------------------
|
|
303
|
+
# Two-phase indexing endpoints (parallel upload + finalize)
|
|
304
|
+
# -------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def upload_batch(self, project_id: str, files: list[dict]) -> UploadResult:
|
|
307
|
+
"""
|
|
308
|
+
Upload and parse files without resolution (Phase 1).
|
|
309
|
+
|
|
310
|
+
Stages files for later finalization. Fast (~2-3s per batch).
|
|
311
|
+
files: [{"path": str, "content": str, "language": str, "hash": str}]
|
|
312
|
+
"""
|
|
313
|
+
resp = self.client.post(
|
|
314
|
+
"/v1/index/upload",
|
|
315
|
+
json={"project_id": project_id, "files": files},
|
|
316
|
+
)
|
|
317
|
+
resp.raise_for_status()
|
|
318
|
+
data = resp.json()
|
|
319
|
+
return UploadResult(
|
|
320
|
+
files_parsed=data["files_parsed"],
|
|
321
|
+
total_staged=data["total_staged"],
|
|
322
|
+
functions_found=data["functions_found"],
|
|
323
|
+
classes_found=data["classes_found"],
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def finalize_stream(self, project_id: str) -> Iterator[IndexProgress]:
|
|
327
|
+
"""
|
|
328
|
+
Finalize staged files with global resolution (Phase 2).
|
|
329
|
+
|
|
330
|
+
Performs: imports resolution, call graph building, edge creation, embeddings.
|
|
331
|
+
Returns SSE stream with progress updates.
|
|
332
|
+
"""
|
|
333
|
+
url = f"{self.base_url}/v1/index/finalize/stream"
|
|
334
|
+
payload = {"project_id": project_id}
|
|
335
|
+
|
|
336
|
+
with httpx.stream(
|
|
337
|
+
"POST",
|
|
338
|
+
url,
|
|
339
|
+
json=payload,
|
|
340
|
+
headers={**self._headers(), "Accept": "text/event-stream"},
|
|
341
|
+
timeout=600.0, # Finalization can take longer for large projects
|
|
342
|
+
) as response:
|
|
343
|
+
response.raise_for_status()
|
|
344
|
+
for line in response.iter_lines():
|
|
345
|
+
if line.startswith("data: "):
|
|
346
|
+
data = json.loads(line[6:])
|
|
347
|
+
yield IndexProgress(
|
|
348
|
+
stage=data["stage"],
|
|
349
|
+
message=data["message"],
|
|
350
|
+
current=data["current"],
|
|
351
|
+
total=data["total"],
|
|
352
|
+
files_indexed=data.get("files_indexed"),
|
|
353
|
+
files_skipped=data.get("files_skipped"),
|
|
354
|
+
nodes_created=data.get("nodes_created"),
|
|
355
|
+
edges_created=data.get("edges_created"),
|
|
356
|
+
errors=data.get("errors"),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def get_staging_stats(self, project_id: str) -> Optional[StagingStats]:
|
|
360
|
+
"""
|
|
361
|
+
Get staging stats for a project.
|
|
362
|
+
|
|
363
|
+
Returns None if server doesn't support two-phase indexing (404 or unexpected response).
|
|
364
|
+
"""
|
|
365
|
+
resp = self.client.get(f"/v1/index/staging/{project_id}")
|
|
366
|
+
if resp.status_code == 404:
|
|
367
|
+
return None
|
|
368
|
+
resp.raise_for_status()
|
|
369
|
+
data = resp.json()
|
|
370
|
+
# Check for expected response format
|
|
371
|
+
if "files" not in data:
|
|
372
|
+
return None
|
|
373
|
+
return StagingStats(
|
|
374
|
+
files=data["files"],
|
|
375
|
+
functions=data["functions"],
|
|
376
|
+
classes=data["classes"],
|
|
377
|
+
methods=data["methods"],
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def clear_staging(self, project_id: str) -> bool:
|
|
381
|
+
"""
|
|
382
|
+
Clear staged files for a project (cancel/cleanup).
|
|
383
|
+
|
|
384
|
+
Returns True if cleared, False if nothing to clear.
|
|
385
|
+
"""
|
|
386
|
+
resp = self.client.delete(f"/v1/index/staging/{project_id}")
|
|
387
|
+
if resp.status_code == 404:
|
|
388
|
+
return False
|
|
389
|
+
resp.raise_for_status()
|
|
390
|
+
return True
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands package."""
|
nogic/commands/init.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Init command - initialize a Nogic project."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from nogic.config import Config, CONFIG_DIR, is_dev_mode, get_api_url
|
|
10
|
+
from nogic.api import NogicClient
|
|
11
|
+
from nogic.api.client import get_directory_hash
|
|
12
|
+
from nogic import telemetry, ui
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def init(
|
|
16
|
+
directory: Annotated[Path, typer.Argument(help="Path to the project directory.")] = Path("."),
|
|
17
|
+
project_id: Annotated[Optional[str], typer.Option("--project-id", "-p", help="Use existing project ID.")] = None,
|
|
18
|
+
name: Annotated[Optional[str], typer.Option("--name", "-n", help="Project name.")] = None,
|
|
19
|
+
link: Annotated[bool, typer.Option("--link", help="Re-link an existing project.")] = False,
|
|
20
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Accept defaults, skip prompts.")] = False,
|
|
21
|
+
):
|
|
22
|
+
"""Initialize a Nogic project in a directory."""
|
|
23
|
+
directory = directory.resolve()
|
|
24
|
+
nogic_dir = directory / CONFIG_DIR
|
|
25
|
+
dir_hash = get_directory_hash(str(directory))
|
|
26
|
+
|
|
27
|
+
config = Config.load(directory)
|
|
28
|
+
|
|
29
|
+
if not config.api_key:
|
|
30
|
+
ui.error("Not logged in.")
|
|
31
|
+
ui.dim("Run `nogic login` to authenticate.")
|
|
32
|
+
raise typer.Exit(1)
|
|
33
|
+
|
|
34
|
+
if is_dev_mode():
|
|
35
|
+
ui.dev_banner(get_api_url())
|
|
36
|
+
|
|
37
|
+
client = NogicClient(config)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
if nogic_dir.exists() and not link and config.project_id:
|
|
41
|
+
ui.warn(f"Already initialized: {nogic_dir}")
|
|
42
|
+
ui.kv("Project ID", config.project_id)
|
|
43
|
+
ui.kv("Project Name", config.project_name or "unknown")
|
|
44
|
+
ui.dim("\nUse --link to re-link to a different project.")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if project_id:
|
|
48
|
+
config.project_id = project_id
|
|
49
|
+
config.project_name = name or directory.name
|
|
50
|
+
config.directory_hash = dir_hash
|
|
51
|
+
_finalize_init(directory, nogic_dir, config)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
with ui.status_spinner("Checking for existing project..."):
|
|
55
|
+
existing_project = client.get_project_by_directory(dir_hash)
|
|
56
|
+
|
|
57
|
+
if existing_project:
|
|
58
|
+
ui.info(f"Found existing project '{existing_project.name}'")
|
|
59
|
+
ui.kv("Project ID", existing_project.id)
|
|
60
|
+
|
|
61
|
+
if yes or typer.confirm("Use this project?", default=True):
|
|
62
|
+
config.project_id = existing_project.id
|
|
63
|
+
config.project_name = existing_project.name
|
|
64
|
+
config.directory_hash = dir_hash
|
|
65
|
+
_finalize_init(directory, nogic_dir, config)
|
|
66
|
+
else:
|
|
67
|
+
ui.console.print("\nOptions:")
|
|
68
|
+
ui.console.print(" 1. Wipe graph data and reuse this project")
|
|
69
|
+
ui.console.print(" 2. Abort")
|
|
70
|
+
|
|
71
|
+
choice = "1" if yes else typer.prompt("Choose option", default="1")
|
|
72
|
+
|
|
73
|
+
if choice == "1":
|
|
74
|
+
with ui.status_spinner("Wiping graph data..."):
|
|
75
|
+
result = client.wipe_project_graph(existing_project.id)
|
|
76
|
+
ui.info(f"Deleted {result.nodes_deleted} nodes")
|
|
77
|
+
config.project_id = existing_project.id
|
|
78
|
+
config.project_name = existing_project.name
|
|
79
|
+
config.directory_hash = dir_hash
|
|
80
|
+
_finalize_init(directory, nogic_dir, config)
|
|
81
|
+
else:
|
|
82
|
+
ui.dim("Aborted.")
|
|
83
|
+
raise typer.Exit(0)
|
|
84
|
+
else:
|
|
85
|
+
project_name = name or (directory.name if yes else typer.prompt("Project name", default=directory.name))
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
with ui.status_spinner("Creating project..."):
|
|
89
|
+
project = client.create_project(project_name, dir_hash)
|
|
90
|
+
config.project_id = project.id
|
|
91
|
+
config.project_name = project.name
|
|
92
|
+
config.directory_hash = dir_hash
|
|
93
|
+
ui.success(f"Created project '{project.name}'")
|
|
94
|
+
_finalize_init(directory, nogic_dir, config)
|
|
95
|
+
except httpx.HTTPStatusError as e:
|
|
96
|
+
if e.response.status_code == 409:
|
|
97
|
+
data = e.response.json()
|
|
98
|
+
ui.warn(f"Project already exists: {data.get('project_name')}")
|
|
99
|
+
if yes or typer.confirm("Use this project?", default=True):
|
|
100
|
+
config.project_id = data.get("project_id")
|
|
101
|
+
config.project_name = data.get("project_name")
|
|
102
|
+
config.directory_hash = dir_hash
|
|
103
|
+
_finalize_init(directory, nogic_dir, config)
|
|
104
|
+
else:
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
else:
|
|
107
|
+
ui.error(f"Error creating project ({e.response.status_code})")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
finally:
|
|
110
|
+
client.close()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _finalize_init(directory: Path, nogic_dir: Path, config: Config):
|
|
114
|
+
nogic_dir.mkdir(mode=0o700, exist_ok=True)
|
|
115
|
+
config.save_local(directory)
|
|
116
|
+
|
|
117
|
+
ui.console.print()
|
|
118
|
+
ui.banner("nogic init")
|
|
119
|
+
ui.kv("Directory", str(directory))
|
|
120
|
+
ui.kv("Project", config.project_name or "")
|
|
121
|
+
ui.kv("Project ID", config.project_id or "")
|
|
122
|
+
ui.console.print()
|
|
123
|
+
ui.dim("Ready to sync: nogic watch")
|
|
124
|
+
|
|
125
|
+
telemetry.capture("cli_init", {"project_name": config.project_name})
|
nogic/commands/login.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Login command - authenticate with API key."""
|
|
2
|
+
|
|
3
|
+
import webbrowser
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from nogic.config import Config, is_dev_mode, get_api_url
|
|
10
|
+
from nogic.api import NogicClient
|
|
11
|
+
from nogic import telemetry, ui
|
|
12
|
+
|
|
13
|
+
DASHBOARD_URL = "https://www.nogic.dev/dashboard"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def login(
|
|
17
|
+
api_key: Annotated[Optional[str], typer.Option(
|
|
18
|
+
"--api-key", "-k",
|
|
19
|
+
envvar="NOGIC_API_KEY",
|
|
20
|
+
help="API key (will prompt if not provided).",
|
|
21
|
+
)] = None,
|
|
22
|
+
):
|
|
23
|
+
"""Authenticate with your Nogic API key."""
|
|
24
|
+
if is_dev_mode():
|
|
25
|
+
ui.dev_banner(get_api_url())
|
|
26
|
+
|
|
27
|
+
if not api_key:
|
|
28
|
+
ui.console.print()
|
|
29
|
+
ui.info("Get your API key from the Nogic dashboard.")
|
|
30
|
+
ui.console.print()
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
webbrowser.open(DASHBOARD_URL)
|
|
34
|
+
ui.dim(f" Opened {DASHBOARD_URL}")
|
|
35
|
+
except Exception:
|
|
36
|
+
ui.dim(f" Visit: {DASHBOARD_URL}")
|
|
37
|
+
|
|
38
|
+
ui.console.print()
|
|
39
|
+
api_key = typer.prompt("Paste your API key", hide_input=True)
|
|
40
|
+
|
|
41
|
+
api_key = api_key.strip()
|
|
42
|
+
if not api_key:
|
|
43
|
+
ui.error("No API key provided.")
|
|
44
|
+
raise typer.Exit(1)
|
|
45
|
+
|
|
46
|
+
config = Config.load()
|
|
47
|
+
config.api_key = api_key
|
|
48
|
+
|
|
49
|
+
client = NogicClient(config)
|
|
50
|
+
try:
|
|
51
|
+
with ui.status_spinner("Verifying..."):
|
|
52
|
+
user_info = client.verify_key()
|
|
53
|
+
|
|
54
|
+
ui.console.print()
|
|
55
|
+
ui.success(f"Logged in as {user_info['user_id'][:8]}...")
|
|
56
|
+
config.save_global()
|
|
57
|
+
ui.dim(" API key saved. You're ready to go.")
|
|
58
|
+
ui.console.print()
|
|
59
|
+
ui.dim(" Next: run `nogic init` in a project directory.")
|
|
60
|
+
|
|
61
|
+
telemetry.capture("cli_login", {"success": True})
|
|
62
|
+
telemetry.identify(user_info.get("user_id"))
|
|
63
|
+
|
|
64
|
+
except httpx.HTTPStatusError as e:
|
|
65
|
+
telemetry.capture("cli_login", {"success": False, "error": e.response.status_code})
|
|
66
|
+
if e.response.status_code == 401:
|
|
67
|
+
ui.error("Invalid API key. Please check and try again.")
|
|
68
|
+
else:
|
|
69
|
+
ui.error(f"Authentication failed ({e.response.status_code})")
|
|
70
|
+
raise typer.Exit(1)
|
|
71
|
+
except httpx.RequestError:
|
|
72
|
+
ui.error("Connection failed. Check your network and try again.")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
finally:
|
|
75
|
+
client.close()
|