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.
Files changed (46) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/bin/postinstall.js +29 -0
  4. package/dashboard/__init__.py +1 -1
  5. package/docs/INSTALLATION.md +1 -1
  6. package/mcp/__init__.py +1 -1
  7. package/package.json +11 -2
  8. package/web-app/Dockerfile +59 -0
  9. package/web-app/alembic.ini +43 -0
  10. package/web-app/auth.py +249 -0
  11. package/web-app/crypto.py +83 -0
  12. package/web-app/deploy/k8s/purple-lab/configmap.yaml +8 -0
  13. package/web-app/deploy/k8s/purple-lab/deployment.yaml +69 -0
  14. package/web-app/deploy/k8s/purple-lab/hpa.yaml +24 -0
  15. package/web-app/deploy/k8s/purple-lab/ingress.yaml +30 -0
  16. package/web-app/deploy/k8s/purple-lab/networkpolicy.yaml +82 -0
  17. package/web-app/deploy/k8s/purple-lab/pdb.yaml +11 -0
  18. package/web-app/deploy/k8s/purple-lab/postgres.yaml +84 -0
  19. package/web-app/deploy/k8s/purple-lab/pvc.yaml +10 -0
  20. package/web-app/deploy/k8s/purple-lab/secret.yaml +13 -0
  21. package/web-app/deploy/k8s/purple-lab/service.yaml +13 -0
  22. package/web-app/deploy/k8s/purple-lab/serviceaccount.yaml +7 -0
  23. package/web-app/dist/assets/{Badge-btjJpcIR.js → Badge-COZdpB3a.js} +1 -1
  24. package/web-app/dist/assets/{Button-al3e4s_k.js → Button-BBb9-LaD.js} +1 -1
  25. package/web-app/dist/assets/{Card-Dy53Fy1C.js → Card-k2U78J9a.js} +1 -1
  26. package/web-app/dist/assets/{HomePage-CpxX59f6.js → HomePage-BsDnbkbC.js} +1 -1
  27. package/web-app/dist/assets/{LoginPage-C4U4jQQ1.js → LoginPage-DbI9D-2B.js} +1 -1
  28. package/web-app/dist/assets/{NotFoundPage-C-jR50AT.js → NotFoundPage-Cpvhx05J.js} +1 -1
  29. package/web-app/dist/assets/{ProjectPage-nrlfDm3r.js → ProjectPage-D1TEIKzX.js} +15 -14
  30. package/web-app/dist/assets/{ProjectsPage-CswR-Imw.js → ProjectsPage-D07q5yXi.js} +1 -1
  31. package/web-app/dist/assets/{SettingsPage-DyeqtdDr.js → SettingsPage-rj4gED1n.js} +1 -1
  32. package/web-app/dist/assets/{TemplatesPage-CzVB6kTH.js → TemplatesPage-zBW2RQne.js} +1 -1
  33. package/web-app/dist/assets/{TerminalOutput-DCP_fS6c.js → TerminalOutput-D3phVPIe.js} +1 -1
  34. package/web-app/dist/assets/{arrow-left-5SrcW4Rm.js → arrow-left-Al_UelcY.js} +1 -1
  35. package/web-app/dist/assets/{clock-DLN9xqq3.js → clock-DSbHSd8C.js} +1 -1
  36. package/web-app/dist/assets/{external-link-C04MOXed.js → external-link-D24QKyPn.js} +1 -1
  37. package/web-app/dist/assets/{index-BlkhGr5c.js → index-BL1cnbvX.js} +2 -2
  38. package/web-app/dist/index.html +1 -1
  39. package/web-app/docker-compose.purple-lab.yml +76 -0
  40. package/web-app/migrations/env.py +103 -0
  41. package/web-app/migrations/script.py.mako +25 -0
  42. package/web-app/migrations/versions/.gitkeep +0 -0
  43. package/web-app/migrations/versions/001_initial_schema.py +118 -0
  44. package/web-app/models.py +140 -0
  45. package/web-app/requirements.txt +27 -0
  46. 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 (target / "public" / "index.html").exists()
2592
- or (target / "src" / "index.html").exists()
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((target / "package.json").read_text())
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((target / f).exists() for f in ["app.py", "main.py", "server.py"])
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 (target / f).read_text(errors="replace")
2632
+ "fastapi" in (project_root / f).read_text(errors="replace")
2616
2633
  for f in ["app.py", "main.py", "server.py"]
2617
- if (target / f).exists()
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
- await ws.send_text(json.dumps({
3286
- "type": "output",
3287
- "data": "\r\n[Error] pexpect is not installed. "
3288
- "Run: pip install pexpect\r\n",
3289
- }))
3290
- await ws.close()
3291
- return
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)