@palettelab/cli 0.3.23 → 0.3.24

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/README.md CHANGED
@@ -193,6 +193,108 @@ Use the right command for the kind of test you need:
193
193
  For normal app developers, Docker is not required. Docker is only needed for
194
194
  internal platform parity checks with `pltt dev --platform`.
195
195
 
196
+ ## App-Owned Data, Migrations, And Python Backends
197
+
198
+ Palette apps can own their own Python backend, database tables, migrations, and
199
+ organization-scoped data. The generated database template uses this structure:
200
+
201
+ ```text
202
+ my-app/
203
+ palette-plugin.json
204
+ frontend/src/index.tsx
205
+ backend/api/main.py
206
+ backend/api/models.py
207
+ backend/migrations/env.py
208
+ backend/migrations/versions/001_init.py
209
+ ```
210
+
211
+ Declare database ownership in `palette-plugin.json`:
212
+
213
+ ```json
214
+ {
215
+ "capabilities": { "database": true },
216
+ "database": {
217
+ "schema": "app_my_app",
218
+ "migrations": "./backend/migrations"
219
+ }
220
+ }
221
+ ```
222
+
223
+ Define org-scoped models with the backend SDK:
224
+
225
+ ```python
226
+ from sqlalchemy import String
227
+ from sqlalchemy.orm import Mapped, mapped_column
228
+ from palette_sdk import OrgScopedTable
229
+
230
+ class Invoice(OrgScopedTable):
231
+ __tablename__ = "invoices"
232
+
233
+ id: Mapped[int] = mapped_column(primary_key=True)
234
+ customer_name: Mapped[str] = mapped_column(String(255))
235
+ ```
236
+
237
+ Use the migration helper in Alembic migrations:
238
+
239
+ ```python
240
+ from palette_sdk.db import ensure_org_rls
241
+
242
+ def upgrade():
243
+ op.create_table(...)
244
+ ensure_org_rls(op, "invoices")
245
+ ```
246
+
247
+ Use `ctx.repo(Model)` for org-safe CRUD:
248
+
249
+ ```python
250
+ from fastapi import Depends
251
+ from palette_sdk import PluginContext, get_plugin_context
252
+ from models import Invoice
253
+
254
+ @router.get("/invoices")
255
+ async def list_invoices(ctx: PluginContext = Depends(get_plugin_context)):
256
+ rows = await ctx.repo(Invoice).list(order_by="-id")
257
+ return [{"id": r.id, "customer": r.customer_name} for r in rows]
258
+
259
+ @router.post("/invoices")
260
+ async def create_invoice(body: InvoiceIn, ctx: PluginContext = Depends(get_plugin_context)):
261
+ invoice = await ctx.repo(Invoice).create(**body.model_dump())
262
+ return {"id": invoice.id}
263
+ ```
264
+
265
+ Backend SDK features for app-owned data:
266
+
267
+ - `PluginContext` exposes `user_id`, `organization_id`, `plugin_id`, `permissions`, `config`, `storage`, and `ctx.db`.
268
+ - `ctx.repo(Model)` gives org-safe CRUD helpers for app tables.
269
+ - `ctx.has_permission("...")` checks declared permissions.
270
+ - `ctx.config_value("key")` and `ctx.require_config("key")` read app install/config values.
271
+ - `ctx.secret("KEY")` reads app secrets from config or environment variables.
272
+ - `LifecycleHooks` lets apps define install/update/enable/disable/uninstall hooks.
273
+ - `OrgScopedTable` and `PluginBase` keep app data inside the plugin schema model set.
274
+
275
+ Lifecycle example:
276
+
277
+ ```python
278
+ from palette_sdk import LifecycleHooks, PluginContext
279
+
280
+ lifecycle = LifecycleHooks()
281
+
282
+ @lifecycle.on_install
283
+ async def seed_defaults(ctx: PluginContext):
284
+ await ctx.repo(DefaultSetting).create(name="currency", value="USD")
285
+ ```
286
+
287
+ Run local checks before publishing:
288
+
289
+ ```bash
290
+ npx --yes @palettelab/cli@latest test
291
+ npx --yes @palettelab/cli@latest package
292
+ ```
293
+
294
+ The CLI validates manifest shape, SDK compatibility, frontend bundling, backend
295
+ imports, backend route permission gates, declared permissions, migration safety,
296
+ package dependency policy, and backend package size.
297
+
196
298
  ## Commands
197
299
 
198
300
  ### `pltt init <name>`
@@ -2,6 +2,8 @@
2
2
 
3
3
  from palette_sdk.plugin_router import PluginRouter
4
4
  from palette_sdk.plugin_context import PluginContext, get_plugin_context
5
+ from palette_sdk.repository import OrgRepository
6
+ from palette_sdk.lifecycle import LifecycleHooks
5
7
  from palette_sdk.tool_definition import ToolDefinition
6
8
  from palette_sdk.manifest import PluginManifest, load_manifest
7
9
  from palette_sdk.schemas import (
@@ -24,6 +26,8 @@ __all__ = [
24
26
  "PluginRouter",
25
27
  "PluginContext",
26
28
  "get_plugin_context",
29
+ "OrgRepository",
30
+ "LifecycleHooks",
27
31
  "ToolDefinition",
28
32
  "PluginManifest",
29
33
  "load_manifest",
@@ -45,4 +49,4 @@ __all__ = [
45
49
  "route_permission_issues",
46
50
  ]
47
51
 
48
- __version__ = "0.1.3"
52
+ __version__ = "0.1.4"
@@ -0,0 +1,58 @@
1
+ """Lifecycle hook registry for install/update/uninstall behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Literal
8
+
9
+ from palette_sdk.plugin_context import PluginContext
10
+
11
+ LifecycleEvent = Literal["install", "update", "enable", "disable", "uninstall"]
12
+ LifecycleHandler = Callable[[PluginContext], Awaitable[None]]
13
+
14
+
15
+ @dataclass
16
+ class LifecycleHooks:
17
+ """Register optional app lifecycle hooks.
18
+
19
+ App backends can export a `lifecycle` object from `backend/api/main.py`.
20
+ The platform can call these hooks during install/update/uninstall, and tests
21
+ can call `run()` directly.
22
+ """
23
+
24
+ _handlers: dict[LifecycleEvent, list[LifecycleHandler]] = field(
25
+ default_factory=lambda: {
26
+ "install": [],
27
+ "update": [],
28
+ "enable": [],
29
+ "disable": [],
30
+ "uninstall": [],
31
+ }
32
+ )
33
+
34
+ def on(self, event: LifecycleEvent):
35
+ def decorator(fn: LifecycleHandler) -> LifecycleHandler:
36
+ self._handlers[event].append(fn)
37
+ return fn
38
+
39
+ return decorator
40
+
41
+ def on_install(self, fn: LifecycleHandler) -> LifecycleHandler:
42
+ return self.on("install")(fn)
43
+
44
+ def on_update(self, fn: LifecycleHandler) -> LifecycleHandler:
45
+ return self.on("update")(fn)
46
+
47
+ def on_enable(self, fn: LifecycleHandler) -> LifecycleHandler:
48
+ return self.on("enable")(fn)
49
+
50
+ def on_disable(self, fn: LifecycleHandler) -> LifecycleHandler:
51
+ return self.on("disable")(fn)
52
+
53
+ def on_uninstall(self, fn: LifecycleHandler) -> LifecycleHandler:
54
+ return self.on("uninstall")(fn)
55
+
56
+ async def run(self, event: LifecycleEvent, ctx: PluginContext) -> None:
57
+ for handler in self._handlers[event]:
58
+ await handler(ctx)
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
+ import logging
7
+ import os
6
8
  from typing import Any
7
9
 
8
10
  from fastapi import Depends, Request
@@ -35,6 +37,29 @@ class PluginContext:
35
37
  permissions: list[str] = field(default_factory=list)
36
38
  storage: Any = None # Platform storage service injected at runtime
37
39
  config: dict[str, Any] = field(default_factory=dict)
40
+ logger: logging.Logger = field(default_factory=lambda: logging.getLogger("palette_sdk.plugin"))
41
+
42
+ def has_permission(self, permission: str) -> bool:
43
+ return permission in self.permissions
44
+
45
+ def config_value(self, key: str, default: Any = None) -> Any:
46
+ return self.config.get(key, default)
47
+
48
+ def require_config(self, key: str) -> Any:
49
+ if key not in self.config or self.config[key] in (None, ""):
50
+ raise KeyError(f"missing plugin config value: {key}")
51
+ return self.config[key]
52
+
53
+ def secret(self, key: str, default: str | None = None) -> str | None:
54
+ secrets = self.config.get("secrets")
55
+ if isinstance(secrets, dict) and key in secrets:
56
+ return secrets[key]
57
+ return os.environ.get(key, default)
58
+
59
+ def repo(self, model: type[Any]):
60
+ from palette_sdk.repository import OrgRepository
61
+
62
+ return OrgRepository(self.db, model, self.organization_id)
38
63
 
39
64
 
40
65
  async def get_plugin_context(request: Request) -> PluginContext:
@@ -60,4 +85,5 @@ async def get_plugin_context(request: Request) -> PluginContext:
60
85
  permissions=getattr(state, "plugin_permissions", []),
61
86
  storage=getattr(state, "storage", None),
62
87
  config=getattr(state, "plugin_config", {}),
88
+ logger=getattr(state, "plugin_logger", logging.getLogger(f"palette_sdk.plugin.{getattr(state, 'plugin_id', 'unknown')}")),
63
89
  )
@@ -0,0 +1,119 @@
1
+ """Org-safe repository helpers for plugin-owned data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any, Generic, TypeVar
7
+
8
+ from sqlalchemy import asc, desc, func, select
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ class OrgRepository(Generic[T]):
15
+ """Small CRUD helper for plugin models.
16
+
17
+ The platform still enforces plugin schema isolation and database RLS in
18
+ production. This helper adds the same organization guard in app code, which
19
+ keeps local SQLite/dev tests honest and reduces repeated query boilerplate.
20
+ """
21
+
22
+ def __init__(self, db: AsyncSession, model: type[T], organization_id: int):
23
+ self.db = db
24
+ self.model = model
25
+ self.organization_id = organization_id
26
+
27
+ def _org_filter(self):
28
+ if hasattr(self.model, "organization_id"):
29
+ return self.model.organization_id == self.organization_id
30
+ return None
31
+
32
+ def _base_select(self):
33
+ stmt = select(self.model)
34
+ org_filter = self._org_filter()
35
+ if org_filter is not None:
36
+ stmt = stmt.where(org_filter)
37
+ return stmt
38
+
39
+ def _apply_filters(self, stmt, filters: Mapping[str, Any] | None):
40
+ for key, value in (filters or {}).items():
41
+ if not hasattr(self.model, key):
42
+ raise ValueError(f"{self.model.__name__} has no column {key!r}")
43
+ stmt = stmt.where(getattr(self.model, key) == value)
44
+ return stmt
45
+
46
+ def _apply_order(self, stmt, order_by: str | Sequence[str] | None):
47
+ if order_by is None:
48
+ return stmt
49
+ fields = [order_by] if isinstance(order_by, str) else list(order_by)
50
+ clauses = []
51
+ for field in fields:
52
+ direction = desc if field.startswith("-") else asc
53
+ key = field[1:] if field.startswith("-") else field
54
+ if not hasattr(self.model, key):
55
+ raise ValueError(f"{self.model.__name__} has no column {key!r}")
56
+ clauses.append(direction(getattr(self.model, key)))
57
+ return stmt.order_by(*clauses)
58
+
59
+ async def list(
60
+ self,
61
+ *,
62
+ filters: Mapping[str, Any] | None = None,
63
+ order_by: str | Sequence[str] | None = None,
64
+ limit: int = 100,
65
+ offset: int = 0,
66
+ ) -> list[T]:
67
+ stmt = self._apply_filters(self._base_select(), filters)
68
+ stmt = self._apply_order(stmt, order_by)
69
+ stmt = stmt.limit(max(1, min(limit, 500))).offset(max(0, offset))
70
+ return list((await self.db.execute(stmt)).scalars().all())
71
+
72
+ async def count(self, *, filters: Mapping[str, Any] | None = None) -> int:
73
+ stmt = select(func.count()).select_from(self.model)
74
+ org_filter = self._org_filter()
75
+ if org_filter is not None:
76
+ stmt = stmt.where(org_filter)
77
+ stmt = self._apply_filters(stmt, filters)
78
+ return int((await self.db.execute(stmt)).scalar_one())
79
+
80
+ async def get(self, id: Any) -> T | None:
81
+ stmt = self._base_select().where(self.model.id == id)
82
+ return (await self.db.execute(stmt)).scalar_one_or_none()
83
+
84
+ async def require(self, id: Any) -> T:
85
+ row = await self.get(id)
86
+ if row is None:
87
+ raise LookupError(f"{self.model.__name__} {id!r} not found")
88
+ return row
89
+
90
+ async def create(self, **values: Any) -> T:
91
+ if hasattr(self.model, "organization_id"):
92
+ values["organization_id"] = self.organization_id
93
+ row = self.model(**values)
94
+ self.db.add(row)
95
+ await self.db.commit()
96
+ await self.db.refresh(row)
97
+ return row
98
+
99
+ async def update(self, id: Any, **values: Any) -> T | None:
100
+ row = await self.get(id)
101
+ if row is None:
102
+ return None
103
+ values.pop("id", None)
104
+ values.pop("organization_id", None)
105
+ for key, value in values.items():
106
+ if not hasattr(row, key):
107
+ raise ValueError(f"{self.model.__name__} has no attribute {key!r}")
108
+ setattr(row, key, value)
109
+ await self.db.commit()
110
+ await self.db.refresh(row)
111
+ return row
112
+
113
+ async def delete(self, id: Any) -> bool:
114
+ row = await self.get(id)
115
+ if row is None:
116
+ return False
117
+ await self.db.delete(row)
118
+ await self.db.commit()
119
+ return True
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "palette-sdk"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "Palette Platform SDK for building backend plugins"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -250,9 +250,11 @@ const platform = {
250
250
  org_role: "owner",
251
251
  },
252
252
  organizationId: 1,
253
+ pluginId: ${JSON.stringify(manifest.id)},
253
254
  orgRole: "owner",
254
255
  orgs: [{ id: 1, name: "Local Dev Org", slug: "local-dev", theme_id: "default", logo_url: null }],
255
256
  agents: [],
257
+ permissions: ${JSON.stringify(manifest.permissions || [])},
256
258
  apiFetch,
257
259
  navigate: (path) => window.history.pushState(null, "", path),
258
260
  showToast: (message, type = "info") => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.7"
7
+ "@palettelab/sdk": "^0.1.9"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.7",
6
+ "@palettelab/sdk": "^0.1.9",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -2,8 +2,6 @@
2
2
 
3
3
  from fastapi import Depends, HTTPException
4
4
  from pydantic import BaseModel
5
- from sqlalchemy import select
6
-
7
5
  from palette_sdk import PluginRouter, PluginContext, get_plugin_context, require_permission
8
6
  from models import Note
9
7
 
@@ -16,11 +14,7 @@ class NoteIn(BaseModel):
16
14
 
17
15
  @router.get("/notes", dependencies=[require_permission("resources:read")])
18
16
  async def list_notes(ctx: PluginContext = Depends(get_plugin_context)) -> list[dict]:
19
- rows = (
20
- await ctx.db.execute(
21
- select(Note).order_by(Note.id.desc())
22
- )
23
- ).scalars().all()
17
+ rows = await ctx.repo(Note).list(order_by="-id")
24
18
  return [{"id": r.id, "body": r.body} for r in rows]
25
19
 
26
20
 
@@ -31,8 +25,5 @@ async def create_note(
31
25
  ) -> dict:
32
26
  if not body.body.strip():
33
27
  raise HTTPException(status_code=400, detail="body required")
34
- note = Note(organization_id=ctx.organization_id, body=body.body)
35
- ctx.db.add(note)
36
- await ctx.db.commit()
37
- await ctx.db.refresh(note)
28
+ note = await ctx.repo(Note).create(body=body.body)
38
29
  return {"id": note.id, "body": note.body}
@@ -2,5 +2,5 @@
2
2
  "name": "my-db-plugin",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.7", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.9", "react": "^19.0.0" }
6
6
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-external-svc",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.7", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.9", "react": "^19.0.0" }
6
6
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.7",
6
+ "@palettelab/sdk": "^0.1.9",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }