@palettelab/cli 0.3.17 → 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 +7 -0
- package/lib/commands/init.js +3 -0
- package/lib/dev-simulator.js +44 -5
- package/package.json +1 -1
- package/template-fallback/templates/database/backend/api/main.py +1 -1
- package/template-fallback/templates/database/backend/migrations/env.py +37 -0
- package/template-fallback/templates/database/backend/migrations/{001_init.py → versions/001_init.py} +5 -6
- package/template-fallback/templates/database/frontend/src/index.tsx +156 -30
- package/template-fallback/templates/database/palette-plugin.json +4 -0
- package/template-fallback/templates/database/pyproject.toml +2 -0
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
|
|
package/lib/commands/init.js
CHANGED
|
@@ -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
|
|
package/lib/dev-simulator.js
CHANGED
|
@@ -44,12 +44,17 @@ function pyprojectDependencies(cwd) {
|
|
|
44
44
|
return extractPyprojectDependencies(fs.readFileSync(pyprojectPath, "utf8"))
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function
|
|
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
|
|
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
|
-
|
|
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 }
|
package/package.json
CHANGED
|
@@ -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).
|
|
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()
|
package/template-fallback/templates/database/backend/migrations/{001_init.py → versions/001_init.py}
RENAMED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
"""Initial migration
|
|
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",
|
|
16
|
+
sa.Column("organization_id", sa.BigInteger(), nullable=False),
|
|
18
17
|
sa.Column("body", sa.Text(), nullable=False),
|
|
19
18
|
)
|
|
20
|
-
op.create_index("
|
|
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("
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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="
|
|
38
|
-
<
|
|
39
|
-
<
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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"]
|