loki-mode 6.52.0 → 6.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/bin/postinstall.js +29 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +11 -2
- package/web-app/Dockerfile +59 -0
- package/web-app/alembic.ini +43 -0
- package/web-app/auth.py +249 -0
- package/web-app/crypto.py +83 -0
- package/web-app/deploy/k8s/purple-lab/configmap.yaml +8 -0
- package/web-app/deploy/k8s/purple-lab/deployment.yaml +69 -0
- package/web-app/deploy/k8s/purple-lab/hpa.yaml +24 -0
- package/web-app/deploy/k8s/purple-lab/ingress.yaml +30 -0
- package/web-app/deploy/k8s/purple-lab/networkpolicy.yaml +82 -0
- package/web-app/deploy/k8s/purple-lab/pdb.yaml +11 -0
- package/web-app/deploy/k8s/purple-lab/postgres.yaml +84 -0
- package/web-app/deploy/k8s/purple-lab/pvc.yaml +10 -0
- package/web-app/deploy/k8s/purple-lab/secret.yaml +13 -0
- package/web-app/deploy/k8s/purple-lab/service.yaml +13 -0
- package/web-app/deploy/k8s/purple-lab/serviceaccount.yaml +7 -0
- package/web-app/dist/assets/{Badge-btjJpcIR.js → Badge-COZdpB3a.js} +1 -1
- package/web-app/dist/assets/{Button-al3e4s_k.js → Button-BBb9-LaD.js} +1 -1
- package/web-app/dist/assets/{Card-Dy53Fy1C.js → Card-k2U78J9a.js} +1 -1
- package/web-app/dist/assets/{HomePage-CpxX59f6.js → HomePage-BsDnbkbC.js} +1 -1
- package/web-app/dist/assets/{LoginPage-C4U4jQQ1.js → LoginPage-DbI9D-2B.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-C-jR50AT.js → NotFoundPage-Cpvhx05J.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-nrlfDm3r.js → ProjectPage-D1TEIKzX.js} +15 -14
- package/web-app/dist/assets/{ProjectsPage-CswR-Imw.js → ProjectsPage-D07q5yXi.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-DyeqtdDr.js → SettingsPage-rj4gED1n.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CzVB6kTH.js → TemplatesPage-zBW2RQne.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-DCP_fS6c.js → TerminalOutput-D3phVPIe.js} +1 -1
- package/web-app/dist/assets/{arrow-left-5SrcW4Rm.js → arrow-left-Al_UelcY.js} +1 -1
- package/web-app/dist/assets/{clock-DLN9xqq3.js → clock-DSbHSd8C.js} +1 -1
- package/web-app/dist/assets/{external-link-C04MOXed.js → external-link-D24QKyPn.js} +1 -1
- package/web-app/dist/assets/{index-BlkhGr5c.js → index-BL1cnbvX.js} +2 -2
- package/web-app/dist/index.html +1 -1
- package/web-app/docker-compose.purple-lab.yml +76 -0
- package/web-app/migrations/env.py +103 -0
- package/web-app/migrations/script.py.mako +25 -0
- package/web-app/migrations/versions/.gitkeep +0 -0
- package/web-app/migrations/versions/001_initial_schema.py +118 -0
- package/web-app/models.py +140 -0
- package/web-app/requirements.txt +27 -0
- package/web-app/server.py +42 -14
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Purple Lab database models.
|
|
2
|
+
|
|
3
|
+
Provides SQLAlchemy models for multi-user cloud deployment.
|
|
4
|
+
When no DATABASE_URL is configured, the system falls back to
|
|
5
|
+
file-based storage (local development mode).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import (
|
|
12
|
+
Boolean,
|
|
13
|
+
Column,
|
|
14
|
+
DateTime,
|
|
15
|
+
ForeignKey,
|
|
16
|
+
Integer,
|
|
17
|
+
JSON,
|
|
18
|
+
String,
|
|
19
|
+
Text,
|
|
20
|
+
)
|
|
21
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
23
|
+
from sqlalchemy.orm import DeclarativeBase, relationship
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Base(DeclarativeBase):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class User(Base):
|
|
31
|
+
__tablename__ = "users"
|
|
32
|
+
|
|
33
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
34
|
+
email = Column(String(255), unique=True, nullable=False, index=True)
|
|
35
|
+
name = Column(String(255))
|
|
36
|
+
avatar_url = Column(String(500))
|
|
37
|
+
provider = Column(String(50)) # "github", "google", "email"
|
|
38
|
+
provider_id = Column(String(255)) # External provider user ID
|
|
39
|
+
password_hash = Column(String(255), nullable=True) # For email/password auth
|
|
40
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
41
|
+
last_login = Column(DateTime)
|
|
42
|
+
is_active = Column(Boolean, default=True)
|
|
43
|
+
|
|
44
|
+
sessions = relationship("Session", back_populates="user")
|
|
45
|
+
projects = relationship("Project", back_populates="user")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Project(Base):
|
|
49
|
+
__tablename__ = "projects"
|
|
50
|
+
|
|
51
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
52
|
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
53
|
+
name = Column(String(255), nullable=False)
|
|
54
|
+
description = Column(Text)
|
|
55
|
+
project_dir = Column(String(500), nullable=False)
|
|
56
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
57
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
58
|
+
|
|
59
|
+
user = relationship("User", back_populates="projects")
|
|
60
|
+
sessions = relationship("Session", back_populates="project")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Session(Base):
|
|
64
|
+
__tablename__ = "sessions"
|
|
65
|
+
|
|
66
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
67
|
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
68
|
+
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True)
|
|
69
|
+
prd_content = Column(Text)
|
|
70
|
+
provider = Column(String(50), default="claude")
|
|
71
|
+
mode = Column(String(50), default="standard")
|
|
72
|
+
status = Column(String(50), default="created") # created, running, paused, completed, failed
|
|
73
|
+
started_at = Column(DateTime, default=datetime.utcnow)
|
|
74
|
+
ended_at = Column(DateTime, nullable=True)
|
|
75
|
+
metadata_json = Column(JSON, default=dict)
|
|
76
|
+
|
|
77
|
+
user = relationship("User", back_populates="sessions")
|
|
78
|
+
project = relationship("Project", back_populates="sessions")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Secret(Base):
|
|
82
|
+
__tablename__ = "secrets"
|
|
83
|
+
|
|
84
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
85
|
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
86
|
+
key = Column(String(255), nullable=False)
|
|
87
|
+
encrypted_value = Column(Text, nullable=False) # Fernet encrypted
|
|
88
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
89
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AuditLog(Base):
|
|
93
|
+
__tablename__ = "audit_log"
|
|
94
|
+
|
|
95
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
96
|
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
97
|
+
action = Column(String(100), nullable=False) # "session.start", "file.save", etc.
|
|
98
|
+
resource_type = Column(String(50)) # "session", "file", "secret"
|
|
99
|
+
resource_id = Column(String(255))
|
|
100
|
+
details = Column(JSON, default=dict)
|
|
101
|
+
ip_address = Column(String(45))
|
|
102
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Database connection state
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
DATABASE_URL: str | None = None
|
|
110
|
+
engine = None
|
|
111
|
+
async_session_factory: async_sessionmaker | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def init_db(database_url: str | None = None) -> bool:
|
|
115
|
+
"""Initialize database connection. Returns False if no URL configured (file-based fallback)."""
|
|
116
|
+
global DATABASE_URL, engine, async_session_factory
|
|
117
|
+
|
|
118
|
+
url = database_url or os.environ.get("DATABASE_URL")
|
|
119
|
+
if not url:
|
|
120
|
+
return False # No database configured, use file-based fallback
|
|
121
|
+
|
|
122
|
+
DATABASE_URL = url
|
|
123
|
+
engine = create_async_engine(url, echo=False)
|
|
124
|
+
async_session_factory = async_sessionmaker(
|
|
125
|
+
engine, class_=AsyncSession, expire_on_commit=False
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async with engine.begin() as conn:
|
|
129
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
130
|
+
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def get_db():
|
|
135
|
+
"""Get database session. Yields None if no database configured."""
|
|
136
|
+
if async_session_factory is None:
|
|
137
|
+
yield None
|
|
138
|
+
return
|
|
139
|
+
async with async_session_factory() as session:
|
|
140
|
+
yield session
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Core -- required for server.py to start
|
|
2
|
+
fastapi>=0.100.0
|
|
3
|
+
uvicorn[standard]>=0.20.0
|
|
4
|
+
pydantic>=2.0.0
|
|
5
|
+
httpx>=0.24.0
|
|
6
|
+
|
|
7
|
+
# Terminal -- PTY-based interactive terminal
|
|
8
|
+
pexpect>=4.8.0
|
|
9
|
+
|
|
10
|
+
# File watcher -- live file-change notifications via WebSocket
|
|
11
|
+
watchdog>=3.0.0
|
|
12
|
+
|
|
13
|
+
# Database (optional) -- only needed for multi-user cloud deployment.
|
|
14
|
+
# Without DATABASE_URL set, the server uses file-based storage.
|
|
15
|
+
sqlalchemy[asyncio]>=2.0.0
|
|
16
|
+
asyncpg>=0.28.0
|
|
17
|
+
aiosqlite>=0.19.0
|
|
18
|
+
alembic>=1.12.0
|
|
19
|
+
|
|
20
|
+
# Auth (optional) -- only needed when DATABASE_URL is configured.
|
|
21
|
+
# Imports are guarded by try/except so the server starts without them.
|
|
22
|
+
python-jose[cryptography]>=3.3.0
|
|
23
|
+
passlib[bcrypt]>=1.7.4
|
|
24
|
+
|
|
25
|
+
# Encryption (optional) -- Fernet encryption for user secrets.
|
|
26
|
+
# Needed only for the /api/secrets endpoints in cloud mode.
|
|
27
|
+
cryptography>=41.0.0
|
package/web-app/server.py
CHANGED
|
@@ -2308,6 +2308,14 @@ async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
|
|
|
2308
2308
|
break
|
|
2309
2309
|
# Strip ANSI escape codes for clean display
|
|
2310
2310
|
clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw_line.rstrip("\n"))
|
|
2311
|
+
# Filter out noisy tool-use output lines from Claude
|
|
2312
|
+
stripped = clean.strip()
|
|
2313
|
+
if stripped in ("[Tool: Read]", "[Tool: Bash]", "[Tool: Write]",
|
|
2314
|
+
"[Tool: Edit]", "[Tool: Grep]", "[Tool: Glob]",
|
|
2315
|
+
"[Result]", "[Thinking]"):
|
|
2316
|
+
continue
|
|
2317
|
+
if not stripped:
|
|
2318
|
+
continue
|
|
2311
2319
|
task.output_lines.append(clean)
|
|
2312
2320
|
proc.stdout.close()
|
|
2313
2321
|
|
|
@@ -2583,13 +2591,22 @@ async def get_preview_info(session_id: str) -> JSONResponse:
|
|
|
2583
2591
|
"description": "No preview available",
|
|
2584
2592
|
}
|
|
2585
2593
|
|
|
2586
|
-
# Detect project type from files
|
|
2594
|
+
# Detect project type from files (check root and first-level subdirectories)
|
|
2587
2595
|
files = {f.name for f in target.iterdir() if f.is_file()} if target.is_dir() else set()
|
|
2588
2596
|
has_package_json = "package.json" in files
|
|
2597
|
+
# If package.json not at root, check immediate subdirectories (e.g. project inside a folder)
|
|
2598
|
+
project_root = target
|
|
2599
|
+
if not has_package_json and target.is_dir():
|
|
2600
|
+
for subdir in target.iterdir():
|
|
2601
|
+
if subdir.is_dir() and (subdir / "package.json").exists():
|
|
2602
|
+
project_root = subdir
|
|
2603
|
+
files = {f.name for f in subdir.iterdir() if f.is_file()}
|
|
2604
|
+
has_package_json = True
|
|
2605
|
+
break
|
|
2589
2606
|
has_index_html = (
|
|
2590
2607
|
"index.html" in files
|
|
2591
|
-
or (
|
|
2592
|
-
or (
|
|
2608
|
+
or (project_root / "public" / "index.html").exists()
|
|
2609
|
+
or (project_root / "src" / "index.html").exists()
|
|
2593
2610
|
)
|
|
2594
2611
|
has_pyproject = "pyproject.toml" in files or "setup.py" in files or "requirements.txt" in files
|
|
2595
2612
|
has_go_mod = "go.mod" in files
|
|
@@ -2601,7 +2618,7 @@ async def get_preview_info(session_id: str) -> JSONResponse:
|
|
|
2601
2618
|
pkg_deps: dict = {}
|
|
2602
2619
|
if has_package_json:
|
|
2603
2620
|
try:
|
|
2604
|
-
pkg = json.loads((
|
|
2621
|
+
pkg = json.loads((project_root / "package.json").read_text())
|
|
2605
2622
|
pkg_scripts = pkg.get("scripts", {})
|
|
2606
2623
|
pkg_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
|
2607
2624
|
except (json.JSONDecodeError, OSError):
|
|
@@ -2610,11 +2627,11 @@ async def get_preview_info(session_id: str) -> JSONResponse:
|
|
|
2610
2627
|
# Determine project type and preview strategy
|
|
2611
2628
|
is_react = "react" in pkg_deps or "next" in pkg_deps or "vite" in pkg_deps
|
|
2612
2629
|
is_express = "express" in pkg_deps or "fastify" in pkg_deps or "koa" in pkg_deps or "hono" in pkg_deps
|
|
2613
|
-
is_flask = has_pyproject and any((
|
|
2630
|
+
is_flask = has_pyproject and any((project_root / f).exists() for f in ["app.py", "main.py", "server.py"])
|
|
2614
2631
|
is_fastapi = has_pyproject and any(
|
|
2615
|
-
"fastapi" in (
|
|
2632
|
+
"fastapi" in (project_root / f).read_text(errors="replace")
|
|
2616
2633
|
for f in ["app.py", "main.py", "server.py"]
|
|
2617
|
-
if (
|
|
2634
|
+
if (project_root / f).exists()
|
|
2618
2635
|
)
|
|
2619
2636
|
|
|
2620
2637
|
if is_react or (has_package_json and has_index_html):
|
|
@@ -3282,13 +3299,24 @@ async def terminal_websocket(ws: WebSocket, session_id: str) -> None:
|
|
|
3282
3299
|
await ws.accept()
|
|
3283
3300
|
|
|
3284
3301
|
if not HAS_PEXPECT:
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3302
|
+
# Try to install pexpect automatically
|
|
3303
|
+
import subprocess as _sp
|
|
3304
|
+
try:
|
|
3305
|
+
_sp.run(
|
|
3306
|
+
[sys.executable, "-m", "pip", "install", "--break-system-packages", "pexpect"],
|
|
3307
|
+
capture_output=True, timeout=30,
|
|
3308
|
+
)
|
|
3309
|
+
import pexpect as _pex # noqa: F811
|
|
3310
|
+
globals()["HAS_PEXPECT"] = True
|
|
3311
|
+
except Exception:
|
|
3312
|
+
await ws.send_text(json.dumps({
|
|
3313
|
+
"type": "output",
|
|
3314
|
+
"data": "\r\n[Error] pexpect is not installed and auto-install failed.\r\n"
|
|
3315
|
+
"Run: pip install pexpect\r\n",
|
|
3316
|
+
}))
|
|
3317
|
+
# Close with 4000 = "do not retry" custom code
|
|
3318
|
+
await ws.close(code=4000, reason="pexpect not installed")
|
|
3319
|
+
return
|
|
3292
3320
|
|
|
3293
3321
|
# ---- Reuse or spawn PTY ------------------------------------------------
|
|
3294
3322
|
pty = _terminal_ptys.get(session_id)
|