@palettelab/cli 0.3.16 → 0.3.18

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
@@ -57,11 +57,17 @@ By default this starts:
57
57
  - A small local app shell on the first available port starting at http://localhost:3000
58
58
  - A local FastAPI backend runner on the first available port starting at http://localhost:8000
59
59
  - A mock Palette platform context for `usePlatform()`, toasts, org/user data, and authenticated API calls
60
+ - A plugin-local SQLite database under `.palette/dev/` when `capabilities.database` or `database` is enabled
60
61
 
61
62
  Your frontend entry is bundled and watched. Your backend entry is loaded under
62
63
  `/api/v1/plugins/<your-id>/*` with a dev `PluginContext`, so normal SDK calls
63
64
  work without Docker or platform source.
64
65
 
66
+ For Python apps with database tables, `ctx.db` is an async SQLAlchemy session in
67
+ local dev. The simulator imports `backend/api/models.py` when present and creates
68
+ tables from `palette_sdk.db.PluginBase.metadata`. Production installs still use
69
+ the declared Alembic migrations and platform-managed Postgres/RLS.
70
+
65
71
  `3000` and `8000` are preferred defaults, not hard requirements. If either port is already in use, `pltt dev` automatically picks the next free port and prints the actual URLs.
66
72
 
67
73
  `pltt dev --platform` runs the full Docker `platform-dev` image for deeper
@@ -77,6 +83,7 @@ Environment variables:
77
83
  | `PALETTE_DEV_IMAGE` | `ghcr.io/palette-lab/platform-dev:latest` | Override the platform image for `--platform` |
78
84
  | `PALETTE_FRONTEND_PORT` | `3000` | Preferred starting host port for the frontend |
79
85
  | `PALETTE_BACKEND_PORT` | `8000` | Preferred starting host port for the backend |
86
+ | `PALETTE_DEV_DATABASE_URL` | `.palette/dev/<plugin-id>.sqlite3` | Override the local dev database URL |
80
87
 
81
88
  ### `pltt doctor`
82
89
 
@@ -84,6 +84,9 @@ function rewriteScaffold(destDir, slug, displayName) {
84
84
  const rewrittenManifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"))
85
85
  rewrittenManifest.id = slug
86
86
  rewrittenManifest.name = displayName
87
+ if (rewrittenManifest.database?.schema) {
88
+ rewrittenManifest.database.schema = `app_${slug.replace(/-/g, "_")}`
89
+ }
87
90
  fs.writeFileSync(manifestPath, JSON.stringify(rewrittenManifest, null, 2) + "\n")
88
91
  }
89
92
 
@@ -44,12 +44,17 @@ function pyprojectDependencies(cwd) {
44
44
  return extractPyprojectDependencies(fs.readFileSync(pyprojectPath, "utf8"))
45
45
  }
46
46
 
47
- function ensurePythonEnv(cwd, devDir) {
47
+ function needsDatabase(manifest) {
48
+ return Boolean(manifest.database || manifest.capabilities?.database)
49
+ }
50
+
51
+ function ensurePythonEnv(cwd, devDir, manifest) {
48
52
  const hostPython = process.env.PALETTE_PYTHON || "python3"
49
53
  const venvDir = path.join(devDir, "backend-venv")
50
54
  const venvPython = path.join(venvDir, "bin", "python")
51
55
  const lockPath = path.join(venvDir, ".palette-dev-deps-lock")
52
- const deps = Array.from(new Set([...pyprojectDependencies(cwd), "uvicorn>=0.30.0"]))
56
+ const dbDeps = needsDatabase(manifest) ? ["aiosqlite>=0.20.0", "greenlet>=3.0.0"] : []
57
+ const deps = Array.from(new Set([...pyprojectDependencies(cwd), ...dbDeps, "uvicorn>=0.30.0"]))
53
58
  const lock = JSON.stringify(deps)
54
59
 
55
60
  if (!fs.existsSync(venvPython)) {
@@ -88,10 +93,13 @@ function ensurePythonEnv(cwd, devDir) {
88
93
  function writeBackendRunner(cwd, devDir, manifest, backendEntry) {
89
94
  const runner = path.join(devDir, "backend_runner.py")
90
95
  const sdkPath = localBackendSdkPath()
96
+ const databasePath = path.join(devDir, `${manifest.id}.sqlite3`)
91
97
  const content = `from __future__ import annotations
92
98
 
99
+ import importlib
93
100
  import importlib.util
94
101
  import json
102
+ import os
95
103
  import pathlib
96
104
  import sys
97
105
  from types import SimpleNamespace
@@ -104,6 +112,8 @@ ROOT = pathlib.Path(${JSON.stringify(cwd)}).resolve()
104
112
  ENTRY = pathlib.Path(${JSON.stringify(path.resolve(cwd, backendEntry))}).resolve()
105
113
  MANIFEST = json.loads(${JSON.stringify(JSON.stringify(manifest))})
106
114
  SDK_PATH = ${JSON.stringify(sdkPath || "")}
115
+ DATABASE_ENABLED = bool(MANIFEST.get("database") or MANIFEST.get("capabilities", {}).get("database"))
116
+ DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///${databasePath.replace(/\\/g, "/")}")
107
117
 
108
118
  if SDK_PATH:
109
119
  sys.path.insert(0, SDK_PATH)
@@ -118,9 +128,21 @@ router = getattr(module, "router", None)
118
128
  if router is None:
119
129
  raise RuntimeError(f"backend entry has no router export: {ENTRY}")
120
130
 
131
+ engine = None
132
+ SessionLocal = None
133
+ if DATABASE_ENABLED:
134
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
135
+ from palette_sdk.db import PluginBase
136
+
137
+ models_file = ENTRY.parent / "models.py"
138
+ if models_file.exists():
139
+ importlib.import_module("models")
140
+
141
+ engine = create_async_engine(DATABASE_URL)
142
+ SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
143
+
121
144
  class DevPluginContextMiddleware(BaseHTTPMiddleware):
122
145
  async def dispatch(self, request: Request, call_next):
123
- request.state.db = None
124
146
  request.state.user = SimpleNamespace(
125
147
  id="dev-user",
126
148
  email="developer@palette.local",
@@ -132,7 +154,17 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
132
154
  request.state.plugin_permissions = MANIFEST.get("permissions", [])
133
155
  request.state.plugin_config = {}
134
156
  request.state.storage = None
135
- return await call_next(request)
157
+ if SessionLocal is None:
158
+ request.state.db = None
159
+ return await call_next(request)
160
+
161
+ async with SessionLocal() as session:
162
+ request.state.db = session
163
+ try:
164
+ return await call_next(request)
165
+ except Exception:
166
+ await session.rollback()
167
+ raise
136
168
 
137
169
  app = FastAPI(title=f"{MANIFEST.get('name', 'Palette Plugin')} Local Backend")
138
170
  app.add_middleware(
@@ -144,6 +176,13 @@ app.add_middleware(
144
176
  )
145
177
  app.add_middleware(DevPluginContextMiddleware)
146
178
  app.include_router(router, prefix=f"/api/v1/plugins/{MANIFEST['id']}")
179
+
180
+ @app.on_event("startup")
181
+ async def create_local_database_tables():
182
+ if engine is None:
183
+ return
184
+ async with engine.begin() as conn:
185
+ await conn.run_sync(PluginBase.metadata.create_all)
147
186
  `
148
187
  fs.writeFileSync(runner, content)
149
188
  return runner
@@ -155,7 +194,7 @@ function startBackend(cwd, devDir, manifest, backendPort) {
155
194
  const absEntry = path.resolve(cwd, backendEntry)
156
195
  if (!fs.existsSync(absEntry)) throw new Error(`backend entry not found: ${backendEntry}`)
157
196
 
158
- const python = ensurePythonEnv(cwd, devDir)
197
+ const python = ensurePythonEnv(cwd, devDir, manifest)
159
198
  const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry)
160
199
  const sdkPath = localBackendSdkPath()
161
200
  const env = { ...process.env }
@@ -174,7 +213,7 @@ function simulatorEntrySource(pluginEntry, manifest, backendPort) {
174
213
  return `
175
214
  import React from "react"
176
215
  import { createRoot } from "react-dom/client"
177
- import { PluginProvider } from "@palettelab/sdk/components"
216
+ import { PluginProvider } from "@palettelab/sdk"
178
217
  import Plugin from ${JSON.stringify(pluginEntry)}
179
218
 
180
219
  const backendBase = "http://127.0.0.1:${backendPort}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -18,7 +18,7 @@ class NoteIn(BaseModel):
18
18
  async def list_notes(ctx: PluginContext = Depends(get_plugin_context)) -> list[dict]:
19
19
  rows = (
20
20
  await ctx.db.execute(
21
- select(Note).where(Note.organization_id == ctx.organization_id).order_by(Note.id.desc())
21
+ select(Note).order_by(Note.id.desc())
22
22
  )
23
23
  ).scalars().all()
24
24
  return [{"id": r.id, "body": r.body} for r in rows]
@@ -0,0 +1,37 @@
1
+ """Alembic environment for the plugin database template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from alembic import context
6
+ from sqlalchemy import engine_from_config, pool
7
+
8
+ from models import Note # noqa: F401 - registers model metadata
9
+ from palette_sdk.db import PluginBase
10
+
11
+ config = context.config
12
+ target_metadata = PluginBase.metadata
13
+
14
+
15
+ def run_migrations_offline() -> None:
16
+ url = config.get_main_option("sqlalchemy.url")
17
+ context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
18
+ with context.begin_transaction():
19
+ context.run_migrations()
20
+
21
+
22
+ def run_migrations_online() -> None:
23
+ connectable = engine_from_config(
24
+ config.get_section(config.config_ini_section, {}),
25
+ prefix="sqlalchemy.",
26
+ poolclass=pool.NullPool,
27
+ )
28
+ with connectable.connect() as connection:
29
+ context.configure(connection=connection, target_metadata=target_metadata)
30
+ with context.begin_transaction():
31
+ context.run_migrations()
32
+
33
+
34
+ if context.is_offline_mode():
35
+ run_migrations_offline()
36
+ else:
37
+ run_migrations_online()
@@ -1,8 +1,7 @@
1
- """Initial migration creates notes table with org RLS."""
1
+ """Initial migration - creates notes table with org RLS."""
2
2
 
3
3
  from alembic import op
4
4
  import sqlalchemy as sa
5
- from sqlalchemy.dialects import postgresql
6
5
 
7
6
  from palette_sdk.db import ensure_org_rls
8
7
 
@@ -14,13 +13,13 @@ def upgrade() -> None:
14
13
  op.create_table(
15
14
  "notes",
16
15
  sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
17
- sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False),
16
+ sa.Column("organization_id", sa.BigInteger(), nullable=False),
18
17
  sa.Column("body", sa.Text(), nullable=False),
19
18
  )
20
- op.create_index("ix_notes_organization_id", "notes", ["organization_id"])
21
- ensure_org_rls("notes")
19
+ op.create_index("ix_notes_org", "notes", ["organization_id"])
20
+ ensure_org_rls(op, "notes")
22
21
 
23
22
 
24
23
  def downgrade() -> None:
25
- op.drop_index("ix_notes_organization_id", table_name="notes")
24
+ op.drop_index("ix_notes_org", table_name="notes")
26
25
  op.drop_table("notes")
@@ -6,52 +6,178 @@ import type { PluginComponentProps } from "@palettelab/sdk"
6
6
 
7
7
  type Note = { id: number; body: string }
8
8
 
9
+ const styles = `
10
+ .notes-app {
11
+ min-height: 100vh;
12
+ background: #f6f3ee;
13
+ color: #111827;
14
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
+ }
16
+ .notes-shell {
17
+ width: min(100%, 880px);
18
+ margin: 0 auto;
19
+ padding: 32px 20px;
20
+ }
21
+ .notes-kicker {
22
+ margin: 0 0 8px;
23
+ color: #7c3aed;
24
+ font-size: 12px;
25
+ font-weight: 800;
26
+ letter-spacing: .14em;
27
+ text-transform: uppercase;
28
+ }
29
+ .notes-title {
30
+ margin: 0;
31
+ font-size: clamp(32px, 4vw, 48px);
32
+ line-height: 1;
33
+ }
34
+ .notes-copy {
35
+ max-width: 640px;
36
+ margin: 14px 0 24px;
37
+ color: #667085;
38
+ line-height: 1.65;
39
+ }
40
+ .notes-form {
41
+ display: grid;
42
+ grid-template-columns: minmax(0, 1fr) auto;
43
+ gap: 10px;
44
+ }
45
+ .notes-input {
46
+ min-height: 48px;
47
+ border: 1px solid #c9c3ba;
48
+ background: #fff;
49
+ padding: 0 14px;
50
+ font: inherit;
51
+ outline: none;
52
+ }
53
+ .notes-input:focus {
54
+ border-color: #7c3aed;
55
+ box-shadow: 0 0 0 3px rgba(124, 58, 237, .14);
56
+ }
57
+ .notes-button {
58
+ min-height: 48px;
59
+ border: 0;
60
+ background: #7c3aed;
61
+ color: #fff;
62
+ padding: 0 22px;
63
+ font: inherit;
64
+ font-weight: 800;
65
+ cursor: pointer;
66
+ }
67
+ .notes-button:disabled {
68
+ background: #a7b1c2;
69
+ cursor: not-allowed;
70
+ }
71
+ .notes-list {
72
+ margin-top: 16px;
73
+ border: 1px solid #ded8cf;
74
+ background: #fff;
75
+ box-shadow: 0 18px 40px rgba(31, 41, 51, .08);
76
+ }
77
+ .notes-empty,
78
+ .notes-item {
79
+ margin: 0;
80
+ padding: 16px 18px;
81
+ border-top: 1px solid #ece7df;
82
+ }
83
+ .notes-empty,
84
+ .notes-item:first-child {
85
+ border-top: 0;
86
+ }
87
+ .notes-empty {
88
+ color: #667085;
89
+ }
90
+ @media (max-width: 680px) {
91
+ .notes-form {
92
+ grid-template-columns: 1fr;
93
+ }
94
+ }
95
+ `
96
+
9
97
  export default function NotesApp(_props: PluginComponentProps) {
10
- const { apiFetch, showToast } = usePlatform()
98
+ const { apiFetch, showToast, user } = usePlatform()
11
99
  const [notes, setNotes] = useState<Note[]>([])
12
100
  const [body, setBody] = useState("")
101
+ const [loading, setLoading] = useState(true)
102
+ const [saving, setSaving] = useState(false)
13
103
 
14
104
  async function load() {
15
- const r = await apiFetch("/api/v1/plugins/my-db-plugin/notes")
16
- setNotes(await r.json())
105
+ setLoading(true)
106
+ try {
107
+ const response = await apiFetch("/api/v1/plugins/my-db-plugin/notes")
108
+ if (!response.ok) throw new Error("Could not load notes")
109
+ setNotes(await response.json())
110
+ } finally {
111
+ setLoading(false)
112
+ }
17
113
  }
114
+
18
115
  useEffect(() => {
19
116
  load()
20
- }, [])
117
+ }, [apiFetch])
21
118
 
22
119
  async function add() {
23
- if (!body.trim()) return
24
- const r = await apiFetch("/api/v1/plugins/my-db-plugin/notes", {
25
- method: "POST",
26
- headers: { "Content-Type": "application/json" },
27
- body: JSON.stringify({ body }),
28
- })
29
- if (r.ok) {
120
+ const cleaned = body.trim()
121
+ if (!cleaned) return
122
+ setSaving(true)
123
+ try {
124
+ const response = await apiFetch("/api/v1/plugins/my-db-plugin/notes", {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({ body: cleaned }),
128
+ })
129
+ if (!response.ok) throw new Error("Could not save note")
30
130
  setBody("")
31
131
  showToast("Note saved", "success")
32
- load()
132
+ await load()
133
+ } finally {
134
+ setSaving(false)
33
135
  }
34
136
  }
35
137
 
36
138
  return (
37
- <div className="p-6 space-y-3">
38
- <h1 className="text-2xl font-bold">Notes</h1>
39
- <div className="flex gap-2">
40
- <input
41
- className="border rounded px-2 py-1 text-sm flex-1"
42
- value={body}
43
- onChange={(e) => setBody(e.target.value)}
44
- placeholder="New note…"
45
- />
46
- <button onClick={add} className="px-3 py-1 rounded bg-primary text-primary-foreground text-sm">
47
- Add
48
- </button>
49
- </div>
50
- <ul className="space-y-1 text-sm">
51
- {notes.map((n) => (
52
- <li key={n.id} className="border rounded px-2 py-1">{n.body}</li>
53
- ))}
54
- </ul>
139
+ <div className="notes-app">
140
+ <style>{styles}</style>
141
+ <main className="notes-shell">
142
+ <p className="notes-kicker">Palette DB App</p>
143
+ <h1 className="notes-title">Org Notes</h1>
144
+ <p className="notes-copy">
145
+ Built for {user.name}. This app uses a Python backend and the Palette
146
+ SDK database session exposed as <code>ctx.db</code>.
147
+ </p>
148
+ <section className="notes-form">
149
+ <input
150
+ className="notes-input"
151
+ value={body}
152
+ onChange={(event) => setBody(event.target.value)}
153
+ onKeyDown={(event) => {
154
+ if (event.key === "Enter") void add()
155
+ }}
156
+ placeholder="New note..."
157
+ />
158
+ <button
159
+ type="button"
160
+ onClick={() => void add()}
161
+ disabled={saving || body.trim().length === 0}
162
+ className="notes-button"
163
+ >
164
+ {saving ? "Saving" : "Add Note"}
165
+ </button>
166
+ </section>
167
+ <section className="notes-list">
168
+ {loading ? (
169
+ <p className="notes-empty">Loading notes...</p>
170
+ ) : notes.length === 0 ? (
171
+ <p className="notes-empty">No notes yet.</p>
172
+ ) : (
173
+ notes.map((note) => (
174
+ <p key={note.id} className="notes-item">
175
+ {note.body}
176
+ </p>
177
+ ))
178
+ )}
179
+ </section>
180
+ </main>
55
181
  </div>
56
182
  )
57
183
  }
@@ -20,6 +20,10 @@
20
20
  "file_uploads": false,
21
21
  "external_network": []
22
22
  },
23
+ "database": {
24
+ "schema": "app_my_db_plugin",
25
+ "migrations": "./backend/migrations"
26
+ },
23
27
  "frontend": { "entry": "./frontend/src/index.tsx", "sandbox": true },
24
28
  "backend": { "entry": "./backend/api/main.py" },
25
29
  "permissions": ["resources:read", "resources:write"]
@@ -6,5 +6,7 @@ dependencies = [
6
6
  "fastapi>=0.129.0",
7
7
  "sqlalchemy>=2.0.47",
8
8
  "alembic>=1.17.0",
9
+ "aiosqlite>=0.20.0",
10
+ "greenlet>=3.0.0",
9
11
  # `pltt test` ships the backend SDK on PYTHONPATH.
10
12
  ]