pdd-cli 0.0.90__py3-none-any.whl → 0.0.121__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.
- pdd/__init__.py +38 -6
- pdd/agentic_bug.py +323 -0
- pdd/agentic_bug_orchestrator.py +506 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +537 -0
- pdd/agentic_common.py +533 -770
- pdd/agentic_crash.py +2 -1
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +582 -0
- pdd/agentic_fix.py +118 -3
- pdd/agentic_update.py +27 -9
- pdd/agentic_verify.py +3 -2
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +63 -53
- pdd/auto_include.py +236 -3
- pdd/auto_update.py +125 -47
- pdd/bug_main.py +195 -23
- pdd/cmd_test_main.py +345 -197
- pdd/code_generator.py +4 -2
- pdd/code_generator_main.py +118 -32
- pdd/commands/__init__.py +6 -0
- pdd/commands/analysis.py +113 -48
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +358 -0
- pdd/commands/fix.py +155 -114
- pdd/commands/generate.py +5 -0
- pdd/commands/maintenance.py +3 -2
- pdd/commands/misc.py +8 -0
- pdd/commands/modify.py +225 -163
- pdd/commands/sessions.py +284 -0
- pdd/commands/utility.py +12 -7
- pdd/construct_paths.py +334 -32
- pdd/context_generator_main.py +167 -170
- pdd/continue_generation.py +6 -3
- pdd/core/__init__.py +33 -0
- pdd/core/cli.py +44 -7
- pdd/core/cloud.py +237 -0
- pdd/core/dump.py +68 -20
- pdd/core/errors.py +4 -0
- pdd/core/remote_session.py +61 -0
- pdd/crash_main.py +219 -23
- pdd/data/llm_model.csv +4 -4
- pdd/docs/prompting_guide.md +864 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
- pdd/fix_code_loop.py +208 -34
- pdd/fix_code_module_errors.py +6 -2
- pdd/fix_error_loop.py +291 -38
- pdd/fix_main.py +208 -6
- pdd/fix_verification_errors_loop.py +235 -26
- pdd/fix_verification_main.py +269 -83
- pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
- pdd/frontend/dist/assets/index-CUWd8al1.js +450 -0
- pdd/frontend/dist/index.html +376 -0
- pdd/frontend/dist/logo.svg +33 -0
- pdd/generate_output_paths.py +46 -5
- pdd/generate_test.py +212 -151
- pdd/get_comment.py +19 -44
- pdd/get_extension.py +8 -9
- pdd/get_jwt_token.py +309 -20
- pdd/get_language.py +8 -7
- pdd/get_run_command.py +7 -5
- pdd/insert_includes.py +2 -1
- pdd/llm_invoke.py +531 -97
- pdd/load_prompt_template.py +15 -34
- pdd/operation_log.py +342 -0
- pdd/path_resolution.py +140 -0
- pdd/postprocess.py +122 -97
- pdd/preprocess.py +68 -12
- pdd/preprocess_main.py +33 -1
- pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
- pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
- pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
- pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
- pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
- pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
- pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
- pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
- pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
- pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
- pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
- pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +140 -0
- pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
- pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
- pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
- pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
- pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
- pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
- pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
- pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
- pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
- pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
- pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
- pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
- pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
- pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
- pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
- pdd/prompts/agentic_update_LLM.prompt +192 -338
- pdd/prompts/auto_include_LLM.prompt +22 -0
- pdd/prompts/change_LLM.prompt +3093 -1
- pdd/prompts/detect_change_LLM.prompt +571 -14
- pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
- pdd/prompts/generate_test_LLM.prompt +19 -1
- pdd/prompts/generate_test_from_example_LLM.prompt +366 -0
- pdd/prompts/insert_includes_LLM.prompt +262 -252
- pdd/prompts/prompt_code_diff_LLM.prompt +123 -0
- pdd/prompts/prompt_diff_LLM.prompt +82 -0
- pdd/remote_session.py +876 -0
- pdd/server/__init__.py +52 -0
- pdd/server/app.py +335 -0
- pdd/server/click_executor.py +587 -0
- pdd/server/executor.py +338 -0
- pdd/server/jobs.py +661 -0
- pdd/server/models.py +241 -0
- pdd/server/routes/__init__.py +31 -0
- pdd/server/routes/architecture.py +451 -0
- pdd/server/routes/auth.py +364 -0
- pdd/server/routes/commands.py +929 -0
- pdd/server/routes/config.py +42 -0
- pdd/server/routes/files.py +603 -0
- pdd/server/routes/prompts.py +1347 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +217 -0
- pdd/server/token_counter.py +222 -0
- pdd/summarize_directory.py +236 -237
- pdd/sync_animation.py +8 -4
- pdd/sync_determine_operation.py +329 -47
- pdd/sync_main.py +272 -28
- pdd/sync_orchestration.py +289 -211
- pdd/sync_order.py +304 -0
- pdd/template_expander.py +161 -0
- pdd/templates/architecture/architecture_json.prompt +41 -46
- pdd/trace.py +1 -1
- pdd/track_cost.py +0 -13
- pdd/unfinished_prompt.py +2 -1
- pdd/update_main.py +68 -26
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/METADATA +15 -10
- pdd_cli-0.0.121.dist-info/RECORD +229 -0
- pdd_cli-0.0.90.dist-info/RECORD +0 -153
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/licenses/LICENSE +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/top_level.txt +0 -0
pdd/commands/auth.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
# Internal imports
|
|
16
|
+
try:
|
|
17
|
+
from ..auth_service import (
|
|
18
|
+
get_auth_status,
|
|
19
|
+
logout as service_logout,
|
|
20
|
+
JWT_CACHE_FILE,
|
|
21
|
+
)
|
|
22
|
+
from ..get_jwt_token import (
|
|
23
|
+
get_jwt_token,
|
|
24
|
+
AuthError,
|
|
25
|
+
NetworkError,
|
|
26
|
+
TokenError,
|
|
27
|
+
UserCancelledError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
)
|
|
30
|
+
except ImportError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
# Constants
|
|
36
|
+
PDD_ENV = os.environ.get("PDD_ENV", "local")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_firebase_api_key() -> str:
|
|
40
|
+
"""Load the Firebase API key from environment or .env files."""
|
|
41
|
+
# 1. Check direct env var
|
|
42
|
+
env_key = os.environ.get("NEXT_PUBLIC_FIREBASE_API_KEY")
|
|
43
|
+
if env_key:
|
|
44
|
+
return env_key
|
|
45
|
+
|
|
46
|
+
# 2. Check .env files in current directory
|
|
47
|
+
candidates = [Path(".env"), Path(".env.local")]
|
|
48
|
+
|
|
49
|
+
for candidate in candidates:
|
|
50
|
+
if candidate.exists():
|
|
51
|
+
try:
|
|
52
|
+
content = candidate.read_text(encoding="utf-8")
|
|
53
|
+
for line in content.splitlines():
|
|
54
|
+
if line.strip().startswith("NEXT_PUBLIC_FIREBASE_API_KEY="):
|
|
55
|
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
56
|
+
except Exception:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_client_id() -> Optional[str]:
|
|
63
|
+
"""Get the GitHub Client ID for the current environment."""
|
|
64
|
+
return os.environ.get(f"GITHUB_CLIENT_ID_{PDD_ENV.upper()}") or os.environ.get("GITHUB_CLIENT_ID")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _decode_jwt_payload(token: str) -> Dict[str, Any]:
|
|
68
|
+
"""Decode JWT payload without verification to extract claims."""
|
|
69
|
+
try:
|
|
70
|
+
# JWT is header.payload.signature
|
|
71
|
+
parts = token.split(".")
|
|
72
|
+
if len(parts) != 3:
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
payload = parts[1]
|
|
76
|
+
# Add padding if needed
|
|
77
|
+
padding = len(payload) % 4
|
|
78
|
+
if padding:
|
|
79
|
+
payload += "=" * (4 - padding)
|
|
80
|
+
|
|
81
|
+
decoded = base64.urlsafe_b64decode(payload)
|
|
82
|
+
return json.loads(decoded)
|
|
83
|
+
except Exception:
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@click.group("auth")
|
|
88
|
+
def auth_group():
|
|
89
|
+
"""Manage PDD Cloud authentication."""
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@auth_group.command("login")
|
|
94
|
+
@click.option(
|
|
95
|
+
"--browser/--no-browser",
|
|
96
|
+
default=None,
|
|
97
|
+
help="Control browser opening (auto-detect if not specified)"
|
|
98
|
+
)
|
|
99
|
+
def login(browser: Optional[bool]):
|
|
100
|
+
"""Authenticate with PDD Cloud via GitHub."""
|
|
101
|
+
|
|
102
|
+
api_key = _load_firebase_api_key()
|
|
103
|
+
if not api_key:
|
|
104
|
+
console.print("[red]Error: NEXT_PUBLIC_FIREBASE_API_KEY not found.[/red]")
|
|
105
|
+
console.print("Please set it in your environment or .env file.")
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
client_id = _get_client_id()
|
|
109
|
+
app_name = "PDD CLI"
|
|
110
|
+
|
|
111
|
+
async def run_login():
|
|
112
|
+
try:
|
|
113
|
+
# Import remote session detection
|
|
114
|
+
from ..core.remote_session import should_skip_browser
|
|
115
|
+
|
|
116
|
+
# Determine if browser should be skipped
|
|
117
|
+
skip_browser, reason = should_skip_browser(explicit_flag=browser)
|
|
118
|
+
|
|
119
|
+
if skip_browser:
|
|
120
|
+
console.print(f"[yellow]Note: {reason}[/yellow]")
|
|
121
|
+
console.print("[yellow]Please open the authentication URL manually in a browser.[/yellow]")
|
|
122
|
+
|
|
123
|
+
# Pass no_browser parameter to get_jwt_token
|
|
124
|
+
token = await get_jwt_token(
|
|
125
|
+
firebase_api_key=api_key,
|
|
126
|
+
github_client_id=client_id,
|
|
127
|
+
app_name=app_name,
|
|
128
|
+
no_browser=skip_browser
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not token:
|
|
132
|
+
console.print("[red]Authentication failed: No token received.[/red]")
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
# Decode token to get expiration
|
|
136
|
+
payload = _decode_jwt_payload(token)
|
|
137
|
+
expires_at = payload.get("exp")
|
|
138
|
+
|
|
139
|
+
# Ensure cache directory exists
|
|
140
|
+
if not JWT_CACHE_FILE.parent.exists():
|
|
141
|
+
JWT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
|
|
143
|
+
# Save token and expiration to cache
|
|
144
|
+
# We store id_token for retrieval and expires_at for auth_service checks
|
|
145
|
+
cache_data = {
|
|
146
|
+
"id_token": token,
|
|
147
|
+
"expires_at": expires_at
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
JWT_CACHE_FILE.write_text(json.dumps(cache_data))
|
|
151
|
+
|
|
152
|
+
console.print("[green]Successfully authenticated to PDD Cloud.[/green]")
|
|
153
|
+
|
|
154
|
+
except AuthError as e:
|
|
155
|
+
console.print(f"[red]Authentication failed: {e}[/red]")
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
except NetworkError as e:
|
|
158
|
+
console.print(f"[red]Network error: {e}[/red]")
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
except TokenError as e:
|
|
161
|
+
console.print(f"[red]Token error: {e}[/red]")
|
|
162
|
+
sys.exit(1)
|
|
163
|
+
except UserCancelledError:
|
|
164
|
+
console.print("[yellow]Authentication cancelled by user.[/yellow]")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
except RateLimitError as e:
|
|
167
|
+
console.print(f"[red]Rate limit exceeded: {e}[/red]")
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
console.print(f"[red]An unexpected error occurred: {e}[/red]")
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
|
|
173
|
+
asyncio.run(run_login())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@auth_group.command("status")
|
|
177
|
+
def status():
|
|
178
|
+
"""Check current authentication status."""
|
|
179
|
+
auth_status = get_auth_status()
|
|
180
|
+
|
|
181
|
+
if not auth_status.get("authenticated"):
|
|
182
|
+
console.print("Not authenticated.")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
username = "Unknown"
|
|
186
|
+
|
|
187
|
+
# If we have a cached token, try to extract user info
|
|
188
|
+
if auth_status.get("cached") and JWT_CACHE_FILE.exists():
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(JWT_CACHE_FILE.read_text())
|
|
191
|
+
token = data.get("id_token")
|
|
192
|
+
if token:
|
|
193
|
+
payload = _decode_jwt_payload(token)
|
|
194
|
+
# Try to find a meaningful identifier
|
|
195
|
+
username = payload.get("email") or payload.get("sub")
|
|
196
|
+
|
|
197
|
+
# Check for GitHub specific claims if available in Firebase token
|
|
198
|
+
firebase_claims = payload.get("firebase", {})
|
|
199
|
+
identities = firebase_claims.get("identities", {})
|
|
200
|
+
if "github.com" in identities:
|
|
201
|
+
# identities['github.com'] is a list of IDs, not usernames usually
|
|
202
|
+
pass
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
console.print(f"Authenticated as: [bold green]{username}[/bold green]")
|
|
207
|
+
sys.exit(0)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@auth_group.command("logout")
|
|
211
|
+
def logout_cmd():
|
|
212
|
+
"""Log out of PDD Cloud."""
|
|
213
|
+
success, error = service_logout()
|
|
214
|
+
if success:
|
|
215
|
+
console.print("Logged out of PDD Cloud.")
|
|
216
|
+
else:
|
|
217
|
+
console.print(f"[red]Failed to logout: {error}[/red]")
|
|
218
|
+
# We don't exit with 1 here as partial logout might have occurred
|
|
219
|
+
# and the user is effectively logged out locally anyway.
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@auth_group.command("token")
|
|
223
|
+
@click.option("--format", "output_format", type=click.Choice(["raw", "json"]), default="raw", help="Output format.")
|
|
224
|
+
def token_cmd(output_format: str):
|
|
225
|
+
"""Print the current authentication token."""
|
|
226
|
+
|
|
227
|
+
token_str = None
|
|
228
|
+
expires_at = None
|
|
229
|
+
|
|
230
|
+
# Attempt to read valid token from cache
|
|
231
|
+
if JWT_CACHE_FILE.exists():
|
|
232
|
+
try:
|
|
233
|
+
data = json.loads(JWT_CACHE_FILE.read_text())
|
|
234
|
+
cached_token = data.get("id_token")
|
|
235
|
+
cached_exp = data.get("expires_at")
|
|
236
|
+
|
|
237
|
+
# Simple expiry check
|
|
238
|
+
if cached_token and cached_exp and cached_exp > time.time():
|
|
239
|
+
token_str = cached_token
|
|
240
|
+
expires_at = cached_exp
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
if not token_str:
|
|
245
|
+
# Removed err=True because rich.console.Console.print does not support it
|
|
246
|
+
console.print("[red]No valid token available. Please login.[/red]")
|
|
247
|
+
sys.exit(1)
|
|
248
|
+
|
|
249
|
+
if output_format == "json":
|
|
250
|
+
output = {
|
|
251
|
+
"token": token_str,
|
|
252
|
+
"expires_at": expires_at
|
|
253
|
+
}
|
|
254
|
+
console.print_json(data=output)
|
|
255
|
+
else:
|
|
256
|
+
console.print(token_str)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@auth_group.command("clear-cache")
|
|
260
|
+
def clear_cache():
|
|
261
|
+
"""Clear the JWT token cache.
|
|
262
|
+
|
|
263
|
+
This is useful when:
|
|
264
|
+
- Switching between environments (staging vs production)
|
|
265
|
+
- Experiencing authentication issues
|
|
266
|
+
- JWT token audience mismatch errors
|
|
267
|
+
|
|
268
|
+
After clearing the cache, you'll need to re-authenticate
|
|
269
|
+
with 'pdd auth login' or source the appropriate environment
|
|
270
|
+
setup script (e.g., setup_staging_env.sh).
|
|
271
|
+
"""
|
|
272
|
+
if not JWT_CACHE_FILE.exists():
|
|
273
|
+
console.print("[yellow]No JWT cache found at ~/.pdd/jwt_cache[/yellow]")
|
|
274
|
+
console.print("Nothing to clear.")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
# Try to read cache before deleting to show what was cached
|
|
279
|
+
cache_data = json.loads(JWT_CACHE_FILE.read_text())
|
|
280
|
+
token = cache_data.get("id_token") or cache_data.get("jwt")
|
|
281
|
+
if token:
|
|
282
|
+
payload = _decode_jwt_payload(token)
|
|
283
|
+
aud = payload.get("aud") or payload.get("firebase", {}).get("aud")
|
|
284
|
+
exp = payload.get("exp")
|
|
285
|
+
|
|
286
|
+
console.print("[dim]Cached token info:[/dim]")
|
|
287
|
+
if aud:
|
|
288
|
+
console.print(f" Audience: {aud}")
|
|
289
|
+
if exp:
|
|
290
|
+
if exp > time.time():
|
|
291
|
+
time_remaining = int((exp - time.time()) / 60)
|
|
292
|
+
console.print(f" Expires in: {time_remaining} minutes")
|
|
293
|
+
else:
|
|
294
|
+
console.print(" Status: [red]Expired[/red]")
|
|
295
|
+
except Exception:
|
|
296
|
+
# If we can't read the cache, that's fine - just proceed with deletion
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
# Delete the cache file
|
|
300
|
+
try:
|
|
301
|
+
JWT_CACHE_FILE.unlink()
|
|
302
|
+
console.print("[green]✓[/green] JWT cache cleared successfully")
|
|
303
|
+
console.print()
|
|
304
|
+
console.print("[dim]To re-authenticate:[/dim]")
|
|
305
|
+
console.print(" - For production: [bold]pdd auth login[/bold]")
|
|
306
|
+
console.print(" - For staging: [bold]source setup_staging_env.sh[/bold]")
|
|
307
|
+
except OSError as e:
|
|
308
|
+
console.print(f"[red]Failed to clear cache: {e}[/red]")
|
|
309
|
+
sys.exit(1)
|
pdd/commands/connect.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PDD Connect Command.
|
|
3
|
+
|
|
4
|
+
This module provides the `pdd connect` CLI command which launches a local
|
|
5
|
+
REST server to enable the web frontend to interact with PDD.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import errno
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
import webbrowser
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Default port and range for auto-assignment
|
|
22
|
+
DEFAULT_PORT = 9876
|
|
23
|
+
PORT_RANGE_START = 9876
|
|
24
|
+
PORT_RANGE_END = 9899 # Try up to 24 ports
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_port_available(port: int, host: str = "127.0.0.1") -> bool:
|
|
28
|
+
"""Check if a port is available for binding."""
|
|
29
|
+
try:
|
|
30
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
31
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
32
|
+
s.bind((host, port))
|
|
33
|
+
return True
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
# If we lack permission to bind (common in sandboxed environments),
|
|
36
|
+
# treat availability as unknown and allow the caller to proceed.
|
|
37
|
+
if exc.errno in (errno.EACCES, errno.EPERM):
|
|
38
|
+
return True
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_available_port(start_port: int, end_port: int, host: str = "127.0.0.1") -> Optional[int]:
|
|
43
|
+
"""Find an available port in the given range."""
|
|
44
|
+
for port in range(start_port, end_port + 1):
|
|
45
|
+
if is_port_available(port, host):
|
|
46
|
+
return port
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Handle optional dependencies - uvicorn may not be installed
|
|
50
|
+
try:
|
|
51
|
+
import uvicorn
|
|
52
|
+
except ImportError:
|
|
53
|
+
uvicorn = None
|
|
54
|
+
|
|
55
|
+
# Internal imports
|
|
56
|
+
# We wrap this in a try/except block to allow the module to be imported
|
|
57
|
+
# even if the server dependencies are not present (e.g. in partial environments)
|
|
58
|
+
try:
|
|
59
|
+
from ..server.app import create_app
|
|
60
|
+
except (ImportError, ValueError):
|
|
61
|
+
def create_app(*args, **kwargs):
|
|
62
|
+
raise ImportError("Could not import pdd.server.app.create_app. Ensure server dependencies are installed.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.command("connect")
|
|
66
|
+
@click.option(
|
|
67
|
+
"--port",
|
|
68
|
+
default=9876,
|
|
69
|
+
help="Port to listen on",
|
|
70
|
+
show_default=True,
|
|
71
|
+
type=int,
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--host",
|
|
75
|
+
default="127.0.0.1",
|
|
76
|
+
help="Host to bind to",
|
|
77
|
+
show_default=True,
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"--allow-remote",
|
|
81
|
+
is_flag=True,
|
|
82
|
+
help="Allow non-localhost connections",
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
"--token",
|
|
86
|
+
help="Bearer token for authentication",
|
|
87
|
+
default=None,
|
|
88
|
+
)
|
|
89
|
+
@click.option(
|
|
90
|
+
"--no-browser",
|
|
91
|
+
is_flag=True,
|
|
92
|
+
help="Don't open browser automatically",
|
|
93
|
+
)
|
|
94
|
+
@click.option(
|
|
95
|
+
"--frontend-url",
|
|
96
|
+
help="Custom frontend URL",
|
|
97
|
+
default=None,
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--local-only",
|
|
101
|
+
is_flag=True,
|
|
102
|
+
help="Skip cloud registration (local access only)",
|
|
103
|
+
)
|
|
104
|
+
@click.option(
|
|
105
|
+
"--session-name",
|
|
106
|
+
help="Custom session name for identification",
|
|
107
|
+
default=None,
|
|
108
|
+
)
|
|
109
|
+
@click.pass_context
|
|
110
|
+
def connect(
|
|
111
|
+
ctx: click.Context,
|
|
112
|
+
port: int,
|
|
113
|
+
host: str,
|
|
114
|
+
allow_remote: bool,
|
|
115
|
+
token: Optional[str],
|
|
116
|
+
no_browser: bool,
|
|
117
|
+
frontend_url: Optional[str],
|
|
118
|
+
local_only: bool,
|
|
119
|
+
session_name: Optional[str],
|
|
120
|
+
) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Launch the local REST server for the PDD web frontend.
|
|
123
|
+
|
|
124
|
+
This command starts a FastAPI server that exposes the PDD functionality
|
|
125
|
+
via a REST API. It automatically opens the web interface in your default
|
|
126
|
+
browser unless --no-browser is specified.
|
|
127
|
+
|
|
128
|
+
For authenticated users, the session is automatically registered with
|
|
129
|
+
PDD Cloud for remote access. Use --local-only to skip cloud registration.
|
|
130
|
+
"""
|
|
131
|
+
# Check uvicorn is available
|
|
132
|
+
if uvicorn is None:
|
|
133
|
+
click.echo(click.style("Error: 'uvicorn' is not installed. Please install it to use the connect command.", fg="red"))
|
|
134
|
+
ctx.exit(1)
|
|
135
|
+
|
|
136
|
+
# 1. Determine Project Root
|
|
137
|
+
# We assume the current working directory is the project root
|
|
138
|
+
project_root = Path.cwd()
|
|
139
|
+
|
|
140
|
+
# 2. Security Checks & Configuration
|
|
141
|
+
if allow_remote:
|
|
142
|
+
if not token:
|
|
143
|
+
click.echo(click.style(
|
|
144
|
+
"SECURITY WARNING: You are allowing remote connections without an authentication token.",
|
|
145
|
+
fg="red", bold=True
|
|
146
|
+
))
|
|
147
|
+
click.echo("Anyone with access to your network could execute code on your machine.")
|
|
148
|
+
if not click.confirm("Do you want to proceed?"):
|
|
149
|
+
ctx.exit(1)
|
|
150
|
+
|
|
151
|
+
# If user explicitly asked for remote but left host as localhost,
|
|
152
|
+
# bind to all interfaces to actually allow remote connections.
|
|
153
|
+
if host == "127.0.0.1":
|
|
154
|
+
host = "0.0.0.0"
|
|
155
|
+
click.echo(click.style("Binding to 0.0.0.0 to allow remote connections.", fg="yellow"))
|
|
156
|
+
else:
|
|
157
|
+
# Warn if binding to non-localhost without explicit allow-remote
|
|
158
|
+
if host not in ("127.0.0.1", "localhost"):
|
|
159
|
+
click.echo(click.style(
|
|
160
|
+
f"Warning: Binding to {host} without --allow-remote flag. "
|
|
161
|
+
"External connections may be blocked or insecure.",
|
|
162
|
+
fg="yellow"
|
|
163
|
+
))
|
|
164
|
+
|
|
165
|
+
# 2.5 Smart Port Detection
|
|
166
|
+
# Check if user explicitly specified a port
|
|
167
|
+
port_source = ctx.get_parameter_source("port")
|
|
168
|
+
user_specified_port = port_source == click.core.ParameterSource.COMMANDLINE
|
|
169
|
+
|
|
170
|
+
# For port checking, use the effective bind host
|
|
171
|
+
check_host = "0.0.0.0" if host == "0.0.0.0" else "127.0.0.1"
|
|
172
|
+
|
|
173
|
+
if not is_port_available(port, check_host):
|
|
174
|
+
if user_specified_port:
|
|
175
|
+
# User explicitly requested this port, show error
|
|
176
|
+
click.echo(click.style(
|
|
177
|
+
f"Error: Port {port} is already in use.",
|
|
178
|
+
fg="red", bold=True
|
|
179
|
+
))
|
|
180
|
+
click.echo("Please specify a different port with --port or stop the process using this port.")
|
|
181
|
+
ctx.exit(1)
|
|
182
|
+
else:
|
|
183
|
+
# Auto-detect an available port
|
|
184
|
+
click.echo(click.style(
|
|
185
|
+
f"Port {port} is in use, looking for an available port...",
|
|
186
|
+
fg="yellow"
|
|
187
|
+
))
|
|
188
|
+
available_port = find_available_port(PORT_RANGE_START, PORT_RANGE_END, check_host)
|
|
189
|
+
if available_port is None:
|
|
190
|
+
click.echo(click.style(
|
|
191
|
+
f"Error: No available ports found in range {PORT_RANGE_START}-{PORT_RANGE_END}.",
|
|
192
|
+
fg="red", bold=True
|
|
193
|
+
))
|
|
194
|
+
click.echo("Please specify a port manually with --port or free up a port in this range.")
|
|
195
|
+
ctx.exit(1)
|
|
196
|
+
port = available_port
|
|
197
|
+
click.echo(click.style(
|
|
198
|
+
f"Using port {port} instead.",
|
|
199
|
+
fg="green"
|
|
200
|
+
))
|
|
201
|
+
|
|
202
|
+
# 3. Determine URLs
|
|
203
|
+
# The server URL is where the API lives
|
|
204
|
+
server_url = f"http://{host}:{port}"
|
|
205
|
+
|
|
206
|
+
# The frontend URL is what we open in the browser
|
|
207
|
+
# If binding to 0.0.0.0, we still use localhost for the local browser
|
|
208
|
+
browser_host = "localhost" if host == "0.0.0.0" else host
|
|
209
|
+
target_url = frontend_url if frontend_url else f"http://{browser_host}:{port}"
|
|
210
|
+
|
|
211
|
+
# 4. Configure CORS
|
|
212
|
+
# We need to allow the frontend to talk to the backend
|
|
213
|
+
allowed_origins = [
|
|
214
|
+
"http://localhost:3000",
|
|
215
|
+
"http://127.0.0.1:3000",
|
|
216
|
+
"http://localhost:5173",
|
|
217
|
+
"http://127.0.0.1:5173",
|
|
218
|
+
f"http://localhost:{port}",
|
|
219
|
+
f"http://127.0.0.1:{port}",
|
|
220
|
+
# PDD Cloud frontend
|
|
221
|
+
"https://pdd.dev",
|
|
222
|
+
"https://www.pdd.dev",
|
|
223
|
+
]
|
|
224
|
+
if frontend_url:
|
|
225
|
+
allowed_origins.append(frontend_url)
|
|
226
|
+
|
|
227
|
+
# 4.5 Cloud Session Registration (automatic for authenticated users)
|
|
228
|
+
session_manager = None
|
|
229
|
+
cloud_url = None
|
|
230
|
+
if not local_only:
|
|
231
|
+
try:
|
|
232
|
+
from ..core.cloud import CloudConfig
|
|
233
|
+
from ..remote_session import (
|
|
234
|
+
RemoteSessionManager,
|
|
235
|
+
RemoteSessionError,
|
|
236
|
+
set_active_session_manager,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Check if user is authenticated
|
|
240
|
+
jwt_token = CloudConfig.get_jwt_token(verbose=False)
|
|
241
|
+
if not jwt_token:
|
|
242
|
+
click.echo(click.style(
|
|
243
|
+
"Not authenticated. Running in local-only mode.",
|
|
244
|
+
dim=True
|
|
245
|
+
))
|
|
246
|
+
click.echo(click.style(
|
|
247
|
+
"Run 'pdd login' to enable remote access via cloud.",
|
|
248
|
+
dim=True
|
|
249
|
+
))
|
|
250
|
+
else:
|
|
251
|
+
click.echo("Registering session with PDD Cloud...")
|
|
252
|
+
session_manager = RemoteSessionManager(jwt_token, project_root)
|
|
253
|
+
try:
|
|
254
|
+
# Register with cloud - no public URL needed, cloud hosts everything
|
|
255
|
+
cloud_url = asyncio.run(session_manager.register(
|
|
256
|
+
session_name=session_name,
|
|
257
|
+
))
|
|
258
|
+
# Heartbeat will be started by the app's lifespan manager
|
|
259
|
+
set_active_session_manager(session_manager)
|
|
260
|
+
|
|
261
|
+
click.echo(click.style(
|
|
262
|
+
"Session registered with PDD Cloud!", fg="green", bold=True
|
|
263
|
+
))
|
|
264
|
+
# TODO: Re-enable when production /connect page is deployed
|
|
265
|
+
# click.echo(f" Access URL: {click.style(cloud_url, fg='cyan', underline=True)}")
|
|
266
|
+
# click.echo(click.style(
|
|
267
|
+
# " Share this URL to access your PDD session from any browser.",
|
|
268
|
+
# dim=True
|
|
269
|
+
# ))
|
|
270
|
+
except RemoteSessionError as e:
|
|
271
|
+
click.echo(click.style(
|
|
272
|
+
f"Warning: Failed to register with cloud: {e.message}",
|
|
273
|
+
fg="yellow"
|
|
274
|
+
))
|
|
275
|
+
click.echo(click.style(
|
|
276
|
+
"Running in local-only mode.",
|
|
277
|
+
dim=True
|
|
278
|
+
))
|
|
279
|
+
session_manager = None
|
|
280
|
+
except ImportError as e:
|
|
281
|
+
click.echo(click.style(
|
|
282
|
+
f"Running in local-only mode (cloud dependencies not available).",
|
|
283
|
+
dim=True
|
|
284
|
+
))
|
|
285
|
+
else:
|
|
286
|
+
click.echo(click.style(
|
|
287
|
+
"Running in local-only mode (--local-only flag set).",
|
|
288
|
+
dim=True
|
|
289
|
+
))
|
|
290
|
+
|
|
291
|
+
# 5. Initialize Server App
|
|
292
|
+
try:
|
|
293
|
+
# Pass token via environment variable if provided, as create_app might not take it directly
|
|
294
|
+
if token:
|
|
295
|
+
os.environ["PDD_ACCESS_TOKEN"] = token
|
|
296
|
+
|
|
297
|
+
app = create_app(project_root, allowed_origins=allowed_origins)
|
|
298
|
+
except Exception as e:
|
|
299
|
+
click.echo(click.style(f"Failed to initialize server: {e}", fg="red", bold=True))
|
|
300
|
+
ctx.exit(1)
|
|
301
|
+
|
|
302
|
+
# 6. Print Status Messages
|
|
303
|
+
click.echo(click.style(f"Starting PDD server on {server_url}", fg="green", bold=True))
|
|
304
|
+
click.echo(f"Project Root: {click.style(str(project_root), fg='blue')}")
|
|
305
|
+
click.echo(f"API Documentation: {click.style(f'{server_url}/docs', underline=True)}")
|
|
306
|
+
click.echo(f"Local Frontend: {click.style(target_url, underline=True)}")
|
|
307
|
+
# TODO: Re-enable when production /connect page is deployed
|
|
308
|
+
# if cloud_url:
|
|
309
|
+
# click.echo(f"Remote Access: {click.style(cloud_url, fg='cyan', underline=True)}")
|
|
310
|
+
click.echo(click.style("Press Ctrl+C to stop the server", dim=True))
|
|
311
|
+
|
|
312
|
+
# 7. Open Browser
|
|
313
|
+
if not no_browser:
|
|
314
|
+
# Import remote session detection
|
|
315
|
+
from ..core.remote_session import is_remote_session
|
|
316
|
+
|
|
317
|
+
is_remote, reason = is_remote_session()
|
|
318
|
+
if is_remote:
|
|
319
|
+
click.echo(click.style(f"Note: {reason}", fg="yellow"))
|
|
320
|
+
click.echo("Opening browser may not work in remote sessions. Use the URL above to connect manually.")
|
|
321
|
+
|
|
322
|
+
click.echo("Opening browser...")
|
|
323
|
+
try:
|
|
324
|
+
webbrowser.open(target_url)
|
|
325
|
+
except Exception as e:
|
|
326
|
+
click.echo(click.style(f"Could not open browser: {e}", fg="yellow"))
|
|
327
|
+
click.echo(f"Please open {target_url} manually in your browser.")
|
|
328
|
+
|
|
329
|
+
# 8. Run Server
|
|
330
|
+
try:
|
|
331
|
+
# Run uvicorn
|
|
332
|
+
# Disable access_log to avoid noisy polling logs - custom middleware handles important logging
|
|
333
|
+
uvicorn.run(
|
|
334
|
+
app,
|
|
335
|
+
host=host,
|
|
336
|
+
port=port,
|
|
337
|
+
log_level="warning", # Only show warnings and errors from uvicorn
|
|
338
|
+
access_log=False # Custom middleware handles request logging
|
|
339
|
+
)
|
|
340
|
+
except KeyboardInterrupt:
|
|
341
|
+
click.echo(click.style("\nServer stopping...", fg="yellow", bold=True))
|
|
342
|
+
except Exception as e:
|
|
343
|
+
click.echo(click.style(f"\nServer error: {e}", fg="red", bold=True))
|
|
344
|
+
ctx.exit(1)
|
|
345
|
+
finally:
|
|
346
|
+
# Clean up cloud session if registered
|
|
347
|
+
if session_manager is not None:
|
|
348
|
+
click.echo("Deregistering from PDD Cloud...")
|
|
349
|
+
try:
|
|
350
|
+
from ..remote_session import set_active_session_manager
|
|
351
|
+
asyncio.run(session_manager.stop_heartbeat())
|
|
352
|
+
asyncio.run(session_manager.deregister())
|
|
353
|
+
set_active_session_manager(None)
|
|
354
|
+
click.echo(click.style("Session deregistered.", fg="green"))
|
|
355
|
+
except Exception as e:
|
|
356
|
+
click.echo(click.style(f"Warning: Error during session cleanup: {e}", fg="yellow"))
|
|
357
|
+
|
|
358
|
+
click.echo(click.style("Goodbye!", fg="blue"))
|