@palettelab/cli 0.3.26 → 0.3.28
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 +12 -2
- package/backend-sdk/palette_sdk/db/alembic_env.py +4 -2
- package/docs/python-backend-sdk.md +641 -0
- package/lib/bundler.js +38 -18
- package/lib/commands/build.js +4 -0
- package/lib/dev-simulator.js +30 -1
- package/package.json +2 -1
- package/template-fallback/frontend/src/index.tsx +35 -23
- package/template-fallback/frontend/src/translations.ts +36 -0
- package/template-fallback/package.json +1 -1
- package/template-fallback/palette-plugin.json +1 -1
- package/template-fallback/templates/dashboard/frontend/src/index.tsx +5 -3
- package/template-fallback/templates/dashboard/frontend/src/translations.ts +12 -0
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
- package/template-fallback/templates/database/frontend/src/index.tsx +13 -12
- package/template-fallback/templates/database/frontend/src/translations.ts +30 -0
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/database/palette-plugin.json +1 -1
- package/template-fallback/templates/external-service/frontend/src/index.tsx +5 -3
- package/template-fallback/templates/external-service/frontend/src/translations.ts +12 -0
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/external-service/palette-plugin.json +1 -1
- package/template-fallback/templates/frontend-only/frontend/src/index.tsx +6 -3
- package/template-fallback/templates/frontend-only/frontend/src/translations.ts +12 -0
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/frontend-only/palette-plugin.json +1 -1
package/README.md
CHANGED
|
@@ -195,6 +195,10 @@ internal platform parity checks with `pltt dev --platform`.
|
|
|
195
195
|
|
|
196
196
|
## App-Owned Data, Migrations, And Python Backends
|
|
197
197
|
|
|
198
|
+
For a detailed Python backend SDK guide with route examples, app-owned data,
|
|
199
|
+
migrations, Data Rooms, config, secrets, lifecycle hooks, and hosted sandbox
|
|
200
|
+
testing, see [Python Backend SDK Developer Guide](./docs/python-backend-sdk.md).
|
|
201
|
+
|
|
198
202
|
Palette apps can own their own Python backend, database tables, migrations, and
|
|
199
203
|
organization-scoped data. The generated database template uses this structure:
|
|
200
204
|
|
|
@@ -366,13 +370,19 @@ By default this starts:
|
|
|
366
370
|
|
|
367
371
|
- A small local app shell on the first available port starting at http://localhost:3000
|
|
368
372
|
- A local FastAPI backend runner on the first available port starting at http://localhost:8000
|
|
369
|
-
- A mock Palette platform context for `usePlatform()`, toasts, org/user data, and authenticated API calls
|
|
373
|
+
- A mock Palette platform context for `usePlatform()`, OS language, toasts, org/user data, and authenticated API calls
|
|
370
374
|
- A plugin-local SQLite database under `.palette/dev/` when `capabilities.database` or `database` is enabled
|
|
371
375
|
|
|
372
376
|
Your frontend entry is bundled and watched. Your backend entry is loaded under
|
|
373
377
|
`/api/v1/plugins/<your-id>/*` with a dev `PluginContext`, so normal SDK calls
|
|
374
378
|
work without Docker or platform source.
|
|
375
379
|
|
|
380
|
+
The simulator also provides the same language fields that Palette OS provides:
|
|
381
|
+
`language`, `fallbackLanguage`, `supportedLanguages`, and `setLanguage()`.
|
|
382
|
+
Generated frontend templates include app-owned `frontend/src/translations.ts`
|
|
383
|
+
files wired through `usePluginTranslations()`, so apps can switch when the OS
|
|
384
|
+
language changes without storing copy in the platform.
|
|
385
|
+
|
|
376
386
|
For Python apps with database tables, `ctx.db` is an async SQLAlchemy session in
|
|
377
387
|
local dev. The simulator imports `backend/api/models.py` when present and creates
|
|
378
388
|
tables from `palette_sdk.db.PluginBase.metadata`. Production installs still use
|
|
@@ -495,7 +505,7 @@ If no publish ID is provided, the CLI uses `.palette/last-publish.json` when ava
|
|
|
495
505
|
Fetch or stream telemetry events for a plugin.
|
|
496
506
|
|
|
497
507
|
```bash
|
|
498
|
-
pltt logs
|
|
508
|
+
pltt logs my-plugin --env staging
|
|
499
509
|
pltt logs --tail 100
|
|
500
510
|
pltt logs --follow
|
|
501
511
|
pltt logs --json
|
|
@@ -11,8 +11,10 @@ invokes Alembic:
|
|
|
11
11
|
- ``PALETTE_PLUGIN_ID`` — plugin id; determines the target schema
|
|
12
12
|
(``app_<sanitized_id>``) and version table schema.
|
|
13
13
|
- ``PALETTE_DB_URL`` — SQLAlchemy async URL for the plugin's DB.
|
|
14
|
-
- ``PALETTE_PLUGIN_ROLE`` (optional) — role the migration
|
|
15
|
-
|
|
14
|
+
- ``PALETTE_PLUGIN_ROLE`` (optional) — schema-limited role the migration
|
|
15
|
+
should run as; the hosted platform sets this to
|
|
16
|
+
``plugin_migrator_<sanitized_id>``. Local/manual
|
|
17
|
+
runs default to the connecting user.
|
|
16
18
|
|
|
17
19
|
Plugin devs running ``alembic upgrade head`` locally without those vars get
|
|
18
20
|
a helpful error rather than a scribbled-on main DB.
|
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
# Palette Python Backend SDK Developer Guide
|
|
2
|
+
|
|
3
|
+
This guide is for developers building Palette apps with a Python/FastAPI
|
|
4
|
+
backend. Frontend code uses the npm package `@palettelab/sdk`; Python backend
|
|
5
|
+
code uses `palette_sdk`, which is embedded in `@palettelab/cli` for local dev
|
|
6
|
+
and provided by the Palette runtime in hosted sandbox and production.
|
|
7
|
+
|
|
8
|
+
## 1. Where The Backend Runs
|
|
9
|
+
|
|
10
|
+
Palette supports three backend development modes:
|
|
11
|
+
|
|
12
|
+
| Mode | Command | Uses real Palette OS services | Docker |
|
|
13
|
+
|---|---|---:|---:|
|
|
14
|
+
| Local SDK simulator | `pltt dev` | No | No |
|
|
15
|
+
| Hosted sandbox preview | `pltt dev --sandbox --env staging` | Yes | No |
|
|
16
|
+
| Internal local platform parity | `pltt dev --platform` | Local platform container | Yes |
|
|
17
|
+
|
|
18
|
+
Use `pltt dev` for the fastest loop while writing code. Use hosted sandbox
|
|
19
|
+
when the app must use real OS features such as login, organization context,
|
|
20
|
+
Data Rooms, storage, install state, review previews, logs, and platform APIs.
|
|
21
|
+
|
|
22
|
+
## 2. Generated App Structure
|
|
23
|
+
|
|
24
|
+
A Python backend app normally looks like this:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
my-app/
|
|
28
|
+
palette-plugin.json
|
|
29
|
+
frontend/
|
|
30
|
+
src/index.tsx
|
|
31
|
+
backend/
|
|
32
|
+
api/
|
|
33
|
+
main.py
|
|
34
|
+
models.py
|
|
35
|
+
migrations/
|
|
36
|
+
env.py
|
|
37
|
+
versions/
|
|
38
|
+
001_init.py
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The backend entry is declared in `palette-plugin.json`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"id": "finance-tools",
|
|
46
|
+
"name": "Finance Tools",
|
|
47
|
+
"frontend": { "entry": "./frontend/src/index.tsx" },
|
|
48
|
+
"backend": { "entry": "./backend/api/main.py" },
|
|
49
|
+
"permissions": [
|
|
50
|
+
"resources:read",
|
|
51
|
+
"resources:write",
|
|
52
|
+
"data_rooms:read",
|
|
53
|
+
"data_rooms:write"
|
|
54
|
+
],
|
|
55
|
+
"capabilities": { "database": true },
|
|
56
|
+
"database": {
|
|
57
|
+
"schema": "app_finance_tools",
|
|
58
|
+
"migrations": "./backend/migrations"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Declare only the permissions your app actually needs. Backend routes should
|
|
64
|
+
also gate access with `require_permission(...)`.
|
|
65
|
+
|
|
66
|
+
## 3. Basic Backend Route
|
|
67
|
+
|
|
68
|
+
Every backend file exports a `PluginRouter`. Routes are mounted by the platform
|
|
69
|
+
under `/api/v1/plugins/<plugin-id>`.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from fastapi import Depends
|
|
73
|
+
from palette_sdk import PluginRouter, PluginContext, get_plugin_context
|
|
74
|
+
|
|
75
|
+
router = PluginRouter(tags=["finance-tools"])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/me")
|
|
79
|
+
async def me(ctx: PluginContext = Depends(get_plugin_context)):
|
|
80
|
+
return {
|
|
81
|
+
"user_id": ctx.user_id,
|
|
82
|
+
"organization_id": ctx.organization_id,
|
|
83
|
+
"org_role": ctx.org_role,
|
|
84
|
+
"plugin_id": ctx.plugin_id,
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
If the app id is `finance-tools`, this route is available at:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
GET /api/v1/plugins/finance-tools/me
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 4. PluginContext Reference
|
|
95
|
+
|
|
96
|
+
`PluginContext` is injected into each route:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
async def route(ctx: PluginContext = Depends(get_plugin_context)):
|
|
100
|
+
...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Available context values:
|
|
104
|
+
|
|
105
|
+
| Field/helper | Purpose |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `ctx.db` | Async SQLAlchemy session for the app backend |
|
|
108
|
+
| `ctx.user_id` | Authenticated Palette user id |
|
|
109
|
+
| `ctx.organization_id` | Current organization id |
|
|
110
|
+
| `ctx.org_role` | User role in the current organization |
|
|
111
|
+
| `ctx.plugin_id` | Current app/plugin id |
|
|
112
|
+
| `ctx.permissions` | Permissions granted from `palette-plugin.json` |
|
|
113
|
+
| `ctx.storage` | Runtime storage service, when available |
|
|
114
|
+
| `ctx.data_rooms` | Backend Data Room client |
|
|
115
|
+
| `ctx.config` | App install/config values |
|
|
116
|
+
| `ctx.logger` | Runtime logger for backend code |
|
|
117
|
+
| `ctx.repo(Model)` | Org-safe CRUD helper for app-owned tables |
|
|
118
|
+
| `ctx.has_permission(permission)` | Check one declared permission |
|
|
119
|
+
| `ctx.has_any_permission([...])` | Check at least one permission |
|
|
120
|
+
| `ctx.has_all_permissions([...])` | Check every listed permission |
|
|
121
|
+
| `ctx.config_value(key, default)` | Read optional config |
|
|
122
|
+
| `ctx.require_config(key)` | Read required config or raise |
|
|
123
|
+
| `ctx.secret(key, default)` | Read a secret from app config or environment |
|
|
124
|
+
|
|
125
|
+
## 5. Permissions
|
|
126
|
+
|
|
127
|
+
Use route-level permission guards for normal APIs:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from fastapi import Depends
|
|
131
|
+
from palette_sdk import (
|
|
132
|
+
PluginRouter,
|
|
133
|
+
PluginContext,
|
|
134
|
+
get_plugin_context,
|
|
135
|
+
require_permission,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
router = PluginRouter(tags=["finance-tools"])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.get("/reports", dependencies=[require_permission("resources:read")])
|
|
142
|
+
async def reports(ctx: PluginContext = Depends(get_plugin_context)):
|
|
143
|
+
return {"items": []}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Use inline checks only when the route supports multiple behavior paths:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
@router.get("/dashboard", dependencies=[require_permission("resources:read")])
|
|
150
|
+
async def dashboard(ctx: PluginContext = Depends(get_plugin_context)):
|
|
151
|
+
can_write = ctx.has_permission("resources:write")
|
|
152
|
+
can_sync = ctx.has_all_permissions(["data_rooms:read", "data_rooms:write"])
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"can_write": can_write,
|
|
156
|
+
"can_sync": can_sync,
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Known permissions currently include:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
tasks:read, tasks:write
|
|
164
|
+
data_rooms:read, data_rooms:write
|
|
165
|
+
agents:read, agents:write
|
|
166
|
+
chat:read, chat:write
|
|
167
|
+
routines:read, routines:write
|
|
168
|
+
members:read
|
|
169
|
+
resources:read, resources:write
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## 6. App-Owned Data
|
|
173
|
+
|
|
174
|
+
Apps can ship their own tables and migrations. Use `OrgScopedTable` for data
|
|
175
|
+
that belongs to an organization.
|
|
176
|
+
|
|
177
|
+
`backend/api/models.py`:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from sqlalchemy import Numeric, String
|
|
181
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
182
|
+
from palette_sdk import OrgScopedTable
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Invoice(OrgScopedTable):
|
|
186
|
+
__tablename__ = "invoices"
|
|
187
|
+
|
|
188
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
189
|
+
customer_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
190
|
+
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
|
191
|
+
status: Mapped[str] = mapped_column(String(32), default="draft")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`OrgScopedTable` adds `organization_id` automatically. Do not manually set
|
|
195
|
+
`organization_id` in route code; `ctx.repo(Model)` handles it.
|
|
196
|
+
|
|
197
|
+
### Migration Example
|
|
198
|
+
|
|
199
|
+
`backend/migrations/versions/001_init.py`:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from alembic import op
|
|
203
|
+
import sqlalchemy as sa
|
|
204
|
+
from palette_sdk.db import ensure_org_rls
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
revision = "001_init"
|
|
208
|
+
down_revision = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def upgrade():
|
|
212
|
+
op.create_table(
|
|
213
|
+
"invoices",
|
|
214
|
+
sa.Column("id", sa.Integer(), primary_key=True),
|
|
215
|
+
sa.Column("organization_id", sa.BigInteger(), nullable=False),
|
|
216
|
+
sa.Column("customer_name", sa.String(length=255), nullable=False),
|
|
217
|
+
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
|
|
218
|
+
sa.Column("status", sa.String(length=32), nullable=False),
|
|
219
|
+
)
|
|
220
|
+
op.create_index("ix_invoices_org", "invoices", ["organization_id"])
|
|
221
|
+
ensure_org_rls(op, "invoices")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def downgrade():
|
|
225
|
+
op.drop_table("invoices")
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`ensure_org_rls(op, "invoices")` enables row-level security so each
|
|
229
|
+
organization can only access its own rows.
|
|
230
|
+
|
|
231
|
+
## 7. Repository CRUD
|
|
232
|
+
|
|
233
|
+
Use `ctx.repo(Model)` instead of writing repeated org filters.
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from fastapi import Depends, HTTPException
|
|
237
|
+
from pydantic import BaseModel
|
|
238
|
+
from palette_sdk import PluginRouter, PluginContext, get_plugin_context, require_permission
|
|
239
|
+
from models import Invoice
|
|
240
|
+
|
|
241
|
+
router = PluginRouter(tags=["finance-tools"])
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class InvoiceIn(BaseModel):
|
|
245
|
+
customer_name: str
|
|
246
|
+
amount: float
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@router.get("/invoices", dependencies=[require_permission("resources:read")])
|
|
250
|
+
async def list_invoices(ctx: PluginContext = Depends(get_plugin_context)):
|
|
251
|
+
rows = await ctx.repo(Invoice).list(order_by="-id", limit=100)
|
|
252
|
+
return [
|
|
253
|
+
{
|
|
254
|
+
"id": row.id,
|
|
255
|
+
"customer_name": row.customer_name,
|
|
256
|
+
"amount": float(row.amount),
|
|
257
|
+
"status": row.status,
|
|
258
|
+
}
|
|
259
|
+
for row in rows
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@router.post("/invoices", dependencies=[require_permission("resources:write")])
|
|
264
|
+
async def create_invoice(
|
|
265
|
+
body: InvoiceIn,
|
|
266
|
+
ctx: PluginContext = Depends(get_plugin_context),
|
|
267
|
+
):
|
|
268
|
+
invoice = await ctx.repo(Invoice).create(
|
|
269
|
+
customer_name=body.customer_name,
|
|
270
|
+
amount=body.amount,
|
|
271
|
+
)
|
|
272
|
+
return {"id": invoice.id}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@router.patch("/invoices/{invoice_id}", dependencies=[require_permission("resources:write")])
|
|
276
|
+
async def update_invoice(
|
|
277
|
+
invoice_id: int,
|
|
278
|
+
body: InvoiceIn,
|
|
279
|
+
ctx: PluginContext = Depends(get_plugin_context),
|
|
280
|
+
):
|
|
281
|
+
invoice = await ctx.repo(Invoice).update(invoice_id, **body.model_dump())
|
|
282
|
+
if invoice is None:
|
|
283
|
+
raise HTTPException(status_code=404, detail="invoice not found")
|
|
284
|
+
return {"id": invoice.id}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@router.delete("/invoices/{invoice_id}", dependencies=[require_permission("resources:write")])
|
|
288
|
+
async def delete_invoice(
|
|
289
|
+
invoice_id: int,
|
|
290
|
+
ctx: PluginContext = Depends(get_plugin_context),
|
|
291
|
+
):
|
|
292
|
+
deleted = await ctx.repo(Invoice).delete(invoice_id)
|
|
293
|
+
if not deleted:
|
|
294
|
+
raise HTTPException(status_code=404, detail="invoice not found")
|
|
295
|
+
return {"deleted": True}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Repository methods:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
await ctx.repo(Model).list(filters=None, order_by=None, limit=100, offset=0)
|
|
302
|
+
await ctx.repo(Model).count(filters=None)
|
|
303
|
+
await ctx.repo(Model).get(id)
|
|
304
|
+
await ctx.repo(Model).require(id)
|
|
305
|
+
await ctx.repo(Model).create(**values)
|
|
306
|
+
await ctx.repo(Model).update(id, **values)
|
|
307
|
+
await ctx.repo(Model).delete(id)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## 8. Data Rooms From Python
|
|
311
|
+
|
|
312
|
+
Use `ctx.data_rooms` when backend code needs to create folders, find files, or
|
|
313
|
+
read/write documents in Palette Data Rooms.
|
|
314
|
+
|
|
315
|
+
### Create Or Find A Room
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
@router.post("/setup-data-room", dependencies=[require_permission("data_rooms:write")])
|
|
319
|
+
async def setup_data_room(ctx: PluginContext = Depends(get_plugin_context)):
|
|
320
|
+
room = await ctx.data_rooms.ensure_room(
|
|
321
|
+
"Finance",
|
|
322
|
+
description="Finance documents used by the Finance Tools app",
|
|
323
|
+
)
|
|
324
|
+
return room
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Create Custom Folders By Name
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
@router.post("/setup-folders", dependencies=[require_permission("data_rooms:write")])
|
|
331
|
+
async def setup_folders(ctx: PluginContext = Depends(get_plugin_context)):
|
|
332
|
+
room = await ctx.data_rooms.ensure_room("Finance")
|
|
333
|
+
|
|
334
|
+
folder = await ctx.data_rooms.resolve_folder_path(
|
|
335
|
+
room["id"],
|
|
336
|
+
"Clients/Acme/Invoices/2026",
|
|
337
|
+
create=True,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
"room_id": room["id"],
|
|
342
|
+
"folder_id": folder["id"],
|
|
343
|
+
"folder_name": folder["name"],
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`resolve_folder_path(..., create=True)` creates missing folders in the path.
|
|
348
|
+
Pass a list instead of a string when folder names can contain `/`:
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
folder = await ctx.data_rooms.resolve_folder_path(
|
|
352
|
+
room["id"],
|
|
353
|
+
["Clients", "Acme/International", "Invoices"],
|
|
354
|
+
create=True,
|
|
355
|
+
)
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Upload A File
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
@router.post("/write-summary", dependencies=[require_permission("data_rooms:write")])
|
|
362
|
+
async def write_summary(ctx: PluginContext = Depends(get_plugin_context)):
|
|
363
|
+
room = await ctx.data_rooms.ensure_room("Finance")
|
|
364
|
+
folder = await ctx.data_rooms.resolve_folder_path(
|
|
365
|
+
room["id"],
|
|
366
|
+
"Reports/Monthly",
|
|
367
|
+
create=True,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
file = await ctx.data_rooms.upload_file(
|
|
371
|
+
room["id"],
|
|
372
|
+
"summary.txt",
|
|
373
|
+
b"Generated from Python backend",
|
|
374
|
+
folder_id=folder["id"],
|
|
375
|
+
content_type="text/plain",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return file
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Find And Read A File
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
@router.get("/read-summary", dependencies=[require_permission("data_rooms:read")])
|
|
385
|
+
async def read_summary(ctx: PluginContext = Depends(get_plugin_context)):
|
|
386
|
+
room = await ctx.data_rooms.require_room_by_name("Finance")
|
|
387
|
+
folder = await ctx.data_rooms.resolve_folder_path(room["id"], "Reports/Monthly")
|
|
388
|
+
|
|
389
|
+
if folder is None:
|
|
390
|
+
return {"found": False}
|
|
391
|
+
|
|
392
|
+
file = await ctx.data_rooms.find_file_by_name(
|
|
393
|
+
room["id"],
|
|
394
|
+
"summary.txt",
|
|
395
|
+
folder_id=folder["id"],
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if file is None:
|
|
399
|
+
return {"found": False}
|
|
400
|
+
|
|
401
|
+
content = await ctx.data_rooms.read_file_bytes(file["id"])
|
|
402
|
+
return {
|
|
403
|
+
"found": True,
|
|
404
|
+
"text": content.decode("utf-8"),
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### List Room Contents
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
@router.get("/room-contents", dependencies=[require_permission("data_rooms:read")])
|
|
412
|
+
async def room_contents(ctx: PluginContext = Depends(get_plugin_context)):
|
|
413
|
+
room = await ctx.data_rooms.require_room_by_name("Finance")
|
|
414
|
+
contents = await ctx.data_rooms.contents(room["id"])
|
|
415
|
+
return contents
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Available Data Room helpers:
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
await ctx.data_rooms.list()
|
|
422
|
+
await ctx.data_rooms.create(name, description=None)
|
|
423
|
+
await ctx.data_rooms.find_room_by_name(name, case_sensitive=False)
|
|
424
|
+
await ctx.data_rooms.require_room_by_name(name, case_sensitive=False)
|
|
425
|
+
await ctx.data_rooms.ensure_room(name, description=None)
|
|
426
|
+
await ctx.data_rooms.contents(room_id, folder_id=None)
|
|
427
|
+
await ctx.data_rooms.create_folder(room_id, name, parent_folder_id=None)
|
|
428
|
+
await ctx.data_rooms.find_folder_by_name(
|
|
429
|
+
room_id,
|
|
430
|
+
name,
|
|
431
|
+
parent_folder_id=None,
|
|
432
|
+
case_sensitive=False,
|
|
433
|
+
)
|
|
434
|
+
await ctx.data_rooms.ensure_folder(room_id, name, parent_folder_id=None)
|
|
435
|
+
await ctx.data_rooms.resolve_folder_path(
|
|
436
|
+
room_id,
|
|
437
|
+
"A/B/C",
|
|
438
|
+
create=False,
|
|
439
|
+
case_sensitive=False,
|
|
440
|
+
)
|
|
441
|
+
await ctx.data_rooms.find_file_by_name(
|
|
442
|
+
room_id,
|
|
443
|
+
name,
|
|
444
|
+
folder_id=None,
|
|
445
|
+
case_sensitive=False,
|
|
446
|
+
)
|
|
447
|
+
await ctx.data_rooms.read_file_bytes(file_id)
|
|
448
|
+
await ctx.data_rooms.upload_file(
|
|
449
|
+
room_id,
|
|
450
|
+
filename,
|
|
451
|
+
content,
|
|
452
|
+
folder_id=None,
|
|
453
|
+
content_type=None,
|
|
454
|
+
)
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
In local unit tests outside the Palette runtime, inject a fake Data Room service
|
|
458
|
+
if you need to test routes that call `ctx.data_rooms`. In hosted sandbox and
|
|
459
|
+
real OS runtime, the platform injects the real service.
|
|
460
|
+
|
|
461
|
+
## 9. Config And Secrets
|
|
462
|
+
|
|
463
|
+
Use config for app install settings and secrets for sensitive values.
|
|
464
|
+
|
|
465
|
+
```python
|
|
466
|
+
@router.get("/sync-config", dependencies=[require_permission("resources:read")])
|
|
467
|
+
async def sync_config(ctx: PluginContext = Depends(get_plugin_context)):
|
|
468
|
+
timezone = ctx.config_value("timezone", "UTC")
|
|
469
|
+
required_workspace = ctx.require_config("workspace_id")
|
|
470
|
+
api_key = ctx.secret("FINANCE_API_KEY")
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
"timezone": timezone,
|
|
474
|
+
"workspace_id": required_workspace,
|
|
475
|
+
"has_api_key": bool(api_key),
|
|
476
|
+
}
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
`ctx.secret("KEY")` first checks app config secrets and then falls back to the
|
|
480
|
+
process environment variable named `KEY`.
|
|
481
|
+
|
|
482
|
+
## 10. Lifecycle Hooks
|
|
483
|
+
|
|
484
|
+
Lifecycle hooks let an app seed defaults or clean up app-owned data when the
|
|
485
|
+
platform installs, updates, enables, disables, or uninstalls the app.
|
|
486
|
+
|
|
487
|
+
```python
|
|
488
|
+
from palette_sdk import LifecycleHooks, PluginContext
|
|
489
|
+
from models import DefaultSetting
|
|
490
|
+
|
|
491
|
+
lifecycle = LifecycleHooks()
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@lifecycle.on_install
|
|
495
|
+
async def seed_defaults(ctx: PluginContext):
|
|
496
|
+
await ctx.repo(DefaultSetting).create(name="currency", value="USD")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@lifecycle.on_enable
|
|
500
|
+
async def on_enable(ctx: PluginContext):
|
|
501
|
+
ctx.logger.info("finance tools enabled for org %s", ctx.organization_id)
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
## 11. Calling Backend APIs From Frontend
|
|
505
|
+
|
|
506
|
+
Frontend code should call backend routes through the platform API helper, not by
|
|
507
|
+
hardcoding backend origins.
|
|
508
|
+
|
|
509
|
+
```tsx
|
|
510
|
+
import { createPaletteClient } from "@palettelab/sdk"
|
|
511
|
+
|
|
512
|
+
const palette = createPaletteClient()
|
|
513
|
+
|
|
514
|
+
async function loadInvoices() {
|
|
515
|
+
const res = await palette.apiFetch("/api/v1/plugins/finance-tools/invoices")
|
|
516
|
+
return res.json()
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
During `pltt dev`, the simulator routes this to the local backend. In hosted
|
|
521
|
+
sandbox or OS runtime, it goes through the real platform shell and auth context.
|
|
522
|
+
|
|
523
|
+
## 12. Local Development
|
|
524
|
+
|
|
525
|
+
Create and run an app:
|
|
526
|
+
|
|
527
|
+
```bash
|
|
528
|
+
npx --yes @palettelab/cli@latest init finance-tools --template database
|
|
529
|
+
cd finance-tools
|
|
530
|
+
npm install
|
|
531
|
+
npx --yes @palettelab/cli@latest dev
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Useful local checks:
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
npx --yes @palettelab/cli@latest test
|
|
538
|
+
npx --yes @palettelab/cli@latest package
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
The CLI checks manifest shape, SDK compatibility, frontend bundling, backend
|
|
542
|
+
imports, backend route permission gates, declared permissions, migration safety,
|
|
543
|
+
package dependency policy, and backend package size.
|
|
544
|
+
|
|
545
|
+
## 13. Test In Real Palette OS Without Docker
|
|
546
|
+
|
|
547
|
+
Configure the hosted sandbox once:
|
|
548
|
+
|
|
549
|
+
```bash
|
|
550
|
+
npx --yes @palettelab/cli@latest login \
|
|
551
|
+
--env staging \
|
|
552
|
+
--url https://YOUR-PALETTE-STAGING-URL \
|
|
553
|
+
--token pltt_xxxxx
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Then publish a sandbox preview:
|
|
557
|
+
|
|
558
|
+
```bash
|
|
559
|
+
npx --yes @palettelab/cli@latest dev --sandbox --env staging
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
The CLI packages the app, uploads it to the staging Palette environment,
|
|
563
|
+
creates a preview publish, and prints a real OS preview URL. Open that URL to
|
|
564
|
+
test the app with OS shell, auth context, organization context, Data Rooms,
|
|
565
|
+
storage, logs, install/review behavior, and platform APIs.
|
|
566
|
+
|
|
567
|
+
For internal test sandboxes, the backend environment can set:
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
APPSTORE_AUTO_APPROVE_SANDBOX_PREVIEWS=true
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
That lets passing preview publishes become active without manual approval.
|
|
574
|
+
|
|
575
|
+
## 14. Common Mistakes
|
|
576
|
+
|
|
577
|
+
- Do not import `@palettelab/sdk` from Python. That package is for frontend
|
|
578
|
+
JavaScript/React code only.
|
|
579
|
+
- Do not hardcode backend URLs in frontend code. Use the platform API helper.
|
|
580
|
+
- Do not manually set `organization_id` when using `ctx.repo(Model).create`.
|
|
581
|
+
- Do not query cross-org data. The platform runtime and RLS are designed around
|
|
582
|
+
the current organization context.
|
|
583
|
+
- Do not skip `require_permission(...)` on backend routes unless the route is
|
|
584
|
+
intentionally configured as public in the manifest.
|
|
585
|
+
- Do not commit publish tokens, app secrets, or generated `.palette/` local
|
|
586
|
+
state.
|
|
587
|
+
- Use hosted sandbox when you need real Data Rooms, login, organization,
|
|
588
|
+
install, logs, and OS behavior without Docker.
|
|
589
|
+
|
|
590
|
+
## 15. Complete Minimal Example
|
|
591
|
+
|
|
592
|
+
`backend/api/main.py`:
|
|
593
|
+
|
|
594
|
+
```python
|
|
595
|
+
from fastapi import Depends, HTTPException
|
|
596
|
+
from pydantic import BaseModel
|
|
597
|
+
from palette_sdk import PluginRouter, PluginContext, get_plugin_context, require_permission
|
|
598
|
+
from models import Note
|
|
599
|
+
|
|
600
|
+
router = PluginRouter(tags=["notes-app"])
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class NoteIn(BaseModel):
|
|
604
|
+
body: str
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@router.get("/notes", dependencies=[require_permission("resources:read")])
|
|
608
|
+
async def list_notes(ctx: PluginContext = Depends(get_plugin_context)):
|
|
609
|
+
rows = await ctx.repo(Note).list(order_by="-id")
|
|
610
|
+
return [{"id": row.id, "body": row.body} for row in rows]
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@router.post("/notes", dependencies=[require_permission("resources:write")])
|
|
614
|
+
async def create_note(
|
|
615
|
+
body: NoteIn,
|
|
616
|
+
ctx: PluginContext = Depends(get_plugin_context),
|
|
617
|
+
):
|
|
618
|
+
if not body.body.strip():
|
|
619
|
+
raise HTTPException(status_code=400, detail="body required")
|
|
620
|
+
|
|
621
|
+
note = await ctx.repo(Note).create(body=body.body)
|
|
622
|
+
|
|
623
|
+
room = await ctx.data_rooms.ensure_room("Notes")
|
|
624
|
+
folder = await ctx.data_rooms.resolve_folder_path(
|
|
625
|
+
room["id"],
|
|
626
|
+
"Created From Backend",
|
|
627
|
+
create=True,
|
|
628
|
+
)
|
|
629
|
+
await ctx.data_rooms.upload_file(
|
|
630
|
+
room["id"],
|
|
631
|
+
f"note-{note.id}.txt",
|
|
632
|
+
note.body.encode("utf-8"),
|
|
633
|
+
folder_id=folder["id"],
|
|
634
|
+
content_type="text/plain",
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
return {"id": note.id, "body": note.body}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
This route stores app-owned data in the app database and writes a related file
|
|
641
|
+
into Palette Data Rooms from the Python backend.
|
package/lib/bundler.js
CHANGED
|
@@ -132,28 +132,48 @@ async function bundleBackend(pluginDir) {
|
|
|
132
132
|
pluginDir = path.resolve(pluginDir)
|
|
133
133
|
const { spawnSync } = require("child_process")
|
|
134
134
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "palette-bundle-"))
|
|
135
|
+
const stage = path.join(tmp, "stage")
|
|
135
136
|
const outPath = path.join(tmp, "backend.tar.gz")
|
|
136
137
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
try {
|
|
139
|
+
fs.mkdirSync(stage, { recursive: true })
|
|
140
|
+
const copy = (from, to) => {
|
|
141
|
+
fs.cpSync(from, to, {
|
|
142
|
+
recursive: true,
|
|
143
|
+
filter(src) {
|
|
144
|
+
const parts = src.split(path.sep)
|
|
145
|
+
return !parts.includes("__pycache__") && !parts.includes(".venv")
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
}
|
|
143
149
|
|
|
144
|
-
|
|
145
|
-
"
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)
|
|
150
|
+
const backendDir = path.join(pluginDir, "backend")
|
|
151
|
+
if (fs.existsSync(backendDir)) copy(backendDir, path.join(stage, "backend"))
|
|
152
|
+
for (const metadataFile of ["package.json", "pyproject.toml", "palette-plugin.json"]) {
|
|
153
|
+
const src = path.join(pluginDir, metadataFile)
|
|
154
|
+
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(stage, metadataFile))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const sdkDir = path.resolve(__dirname, "..", "backend-sdk", "palette_sdk")
|
|
158
|
+
const targetSdkDir = path.join(stage, "backend", "palette_sdk")
|
|
159
|
+
if (fs.existsSync(sdkDir) && fs.existsSync(path.join(stage, "backend"))) {
|
|
160
|
+
copy(sdkDir, targetSdkDir)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const includes = fs.readdirSync(stage)
|
|
164
|
+
const result = spawnSync("tar", ["-czf", outPath, ...includes], {
|
|
165
|
+
cwd: stage,
|
|
166
|
+
stdio: "pipe",
|
|
167
|
+
})
|
|
168
|
+
if (result.status !== 0) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`tar failed (exit ${result.status}): ${result.stderr?.toString() || "unknown error"}`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
return fs.readFileSync(outPath)
|
|
174
|
+
} finally {
|
|
175
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
153
176
|
}
|
|
154
|
-
const buf = fs.readFileSync(outPath)
|
|
155
|
-
fs.rmSync(tmp, { recursive: true, force: true })
|
|
156
|
-
return buf
|
|
157
177
|
}
|
|
158
178
|
|
|
159
179
|
module.exports = { bundleFrontend, bundleBackend, watchFrontend }
|
package/lib/commands/build.js
CHANGED
|
@@ -13,6 +13,10 @@ const BANNED_PATTERNS = [
|
|
|
13
13
|
{ re: /\bpublic\./, reason: "plugin migrations must not reference the platform's public schema" },
|
|
14
14
|
{ re: /\bSET\s+ROLE\b/i, reason: "SET ROLE in migrations escalates out of the plugin sandbox" },
|
|
15
15
|
{ re: /\bALTER\s+ROLE\b/i, reason: "ALTER ROLE is reserved for the platform" },
|
|
16
|
+
{ re: /\bGRANT\b/i, reason: "GRANT is reserved for the platform" },
|
|
17
|
+
{ re: /\bCREATE\s+EXTENSION\b/i, reason: "CREATE EXTENSION is reserved for the platform" },
|
|
18
|
+
{ re: /\bALTER\s+DATABASE\b/i, reason: "ALTER DATABASE is not allowed in plugin migrations" },
|
|
19
|
+
{ re: /\bDROP\s+SCHEMA\b/i, reason: "DROP SCHEMA is not allowed in plugin migrations" },
|
|
16
20
|
]
|
|
17
21
|
|
|
18
22
|
function lintMigrationFile(absPath) {
|
package/lib/dev-simulator.js
CHANGED
|
@@ -229,7 +229,7 @@ async function apiFetch(path, init) {
|
|
|
229
229
|
return fetch(target, init)
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
const
|
|
232
|
+
const basePlatform = {
|
|
233
233
|
user: {
|
|
234
234
|
id: "dev-user",
|
|
235
235
|
email: "developer@palette.local",
|
|
@@ -263,6 +263,11 @@ const platform = {
|
|
|
263
263
|
},
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
function normalizePaletteLanguage(language, fallback = "en") {
|
|
267
|
+
const value = String(language || "").trim().toLowerCase()
|
|
268
|
+
return value ? value.split("-")[0] : fallback
|
|
269
|
+
}
|
|
270
|
+
|
|
266
271
|
function Toasts() {
|
|
267
272
|
const [items, setItems] = React.useState([])
|
|
268
273
|
React.useEffect(() => {
|
|
@@ -280,6 +285,30 @@ function Toasts() {
|
|
|
280
285
|
}
|
|
281
286
|
|
|
282
287
|
function Shell() {
|
|
288
|
+
const [language, updateLanguage] = React.useState(() => normalizePaletteLanguage(navigator.language))
|
|
289
|
+
const platform = React.useMemo(() => ({
|
|
290
|
+
...basePlatform,
|
|
291
|
+
language,
|
|
292
|
+
fallbackLanguage: "en",
|
|
293
|
+
supportedLanguages: ["en", "ko"],
|
|
294
|
+
setLanguage: (nextLanguage) => {
|
|
295
|
+
const normalized = normalizePaletteLanguage(nextLanguage)
|
|
296
|
+
updateLanguage(normalized)
|
|
297
|
+
document.documentElement.lang = normalized
|
|
298
|
+
window.dispatchEvent(new CustomEvent("palette:language-change", { detail: { language: normalized } }))
|
|
299
|
+
},
|
|
300
|
+
}), [language])
|
|
301
|
+
|
|
302
|
+
React.useEffect(() => {
|
|
303
|
+
document.documentElement.lang = language
|
|
304
|
+
}, [language])
|
|
305
|
+
|
|
306
|
+
React.useEffect(() => {
|
|
307
|
+
const onLanguageChange = () => updateLanguage(normalizePaletteLanguage(navigator.language))
|
|
308
|
+
window.addEventListener("languagechange", onLanguageChange)
|
|
309
|
+
return () => window.removeEventListener("languagechange", onLanguageChange)
|
|
310
|
+
}, [])
|
|
311
|
+
|
|
283
312
|
return React.createElement(PluginProvider, { value: platform },
|
|
284
313
|
React.createElement("main", { className: "palette-local-shell" },
|
|
285
314
|
React.createElement(Plugin, { platform }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@palettelab/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.28",
|
|
4
4
|
"description": "Developer CLI for building Palette platform plugins — no platform source access required.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pltt": "bin/pltt.js"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"bin",
|
|
10
10
|
"backend-sdk",
|
|
11
|
+
"docs",
|
|
11
12
|
"lib",
|
|
12
13
|
"platform-dev",
|
|
13
14
|
"template-fallback",
|
|
@@ -5,46 +5,58 @@
|
|
|
5
5
|
*
|
|
6
6
|
* This is the root component that the platform renders when your plugin is opened.
|
|
7
7
|
* It receives a `platform` prop with access to the authenticated user, API client,
|
|
8
|
-
* navigation, and
|
|
8
|
+
* navigation, toast notifications, and Palette OS language.
|
|
9
9
|
*
|
|
10
10
|
* Available hooks from @palettelab/sdk:
|
|
11
|
-
* - usePlatform()
|
|
12
|
-
* -
|
|
13
|
-
* -
|
|
14
|
-
* -
|
|
11
|
+
* - usePlatform() - access user, org, API, language, navigate, showToast
|
|
12
|
+
* - usePluginTranslations() - translate app-owned resource files using OS language
|
|
13
|
+
* - usePluginTasks() - CRUD tasks
|
|
14
|
+
* - usePluginDataRooms() - browse data rooms and files
|
|
15
|
+
* - usePluginChat() - chat with agents
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
|
-
import { usePlatform, usePluginTasks } from "@palettelab/sdk"
|
|
18
|
+
import { usePlatform, usePluginTasks, usePluginTranslations } from "@palettelab/sdk"
|
|
18
19
|
import type { PluginComponentProps } from "@palettelab/sdk"
|
|
20
|
+
import { translations } from "./translations"
|
|
19
21
|
|
|
20
|
-
export default function MyPlugin(
|
|
22
|
+
export default function MyPlugin(_props: PluginComponentProps) {
|
|
21
23
|
const { user, showToast } = usePlatform()
|
|
22
24
|
const { tasks, loading, createTask } = usePluginTasks()
|
|
25
|
+
const { t, language, setLanguage } = usePluginTranslations(translations)
|
|
23
26
|
|
|
24
27
|
return (
|
|
25
28
|
<div className="p-6 space-y-6">
|
|
26
|
-
<div>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
30
|
+
<div>
|
|
31
|
+
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
|
32
|
+
<p className="text-muted-foreground mt-1">
|
|
33
|
+
{t("greeting", { name: user.name })}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
<button
|
|
37
|
+
className="px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
|
|
38
|
+
onClick={() => setLanguage(language === "ko" ? "en" : "ko")}
|
|
39
|
+
aria-label={t("language")}
|
|
40
|
+
>
|
|
41
|
+
{language === "ko" ? "EN" : "KO"}
|
|
42
|
+
</button>
|
|
31
43
|
</div>
|
|
32
44
|
|
|
33
45
|
<div className="rounded-lg border p-4 space-y-3">
|
|
34
|
-
<h2 className="font-semibold">
|
|
46
|
+
<h2 className="font-semibold">{t("quickStart")}</h2>
|
|
35
47
|
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
|
36
|
-
<li>
|
|
37
|
-
<li>
|
|
38
|
-
<li>
|
|
39
|
-
<li>
|
|
40
|
-
<li>
|
|
48
|
+
<li>{t("editUi")}</li>
|
|
49
|
+
<li>{t("editApi")}</li>
|
|
50
|
+
<li>{t("usePlatform")}</li>
|
|
51
|
+
<li>{t("useTasks")}</li>
|
|
52
|
+
<li>{t("useDataRooms")}</li>
|
|
41
53
|
</ul>
|
|
42
54
|
</div>
|
|
43
55
|
|
|
44
56
|
<div className="rounded-lg border p-4">
|
|
45
|
-
<h2 className="font-semibold mb-2">
|
|
57
|
+
<h2 className="font-semibold mb-2">{t("tasks")} ({loading ? "..." : tasks.length})</h2>
|
|
46
58
|
{!loading && tasks.length === 0 && (
|
|
47
|
-
<p className="text-sm text-muted-foreground">
|
|
59
|
+
<p className="text-sm text-muted-foreground">{t("noTasks")}</p>
|
|
48
60
|
)}
|
|
49
61
|
{tasks.slice(0, 5).map(task => (
|
|
50
62
|
<div key={task.id} className="flex items-center justify-between py-1 text-sm">
|
|
@@ -55,11 +67,11 @@ export default function MyPlugin({ platform }: PluginComponentProps) {
|
|
|
55
67
|
<button
|
|
56
68
|
className="mt-3 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
|
57
69
|
onClick={async () => {
|
|
58
|
-
await createTask({ title: "
|
|
59
|
-
showToast("
|
|
70
|
+
await createTask({ title: t("sampleTaskTitle"), priority: "medium" })
|
|
71
|
+
showToast(t("taskCreated"), "success")
|
|
60
72
|
}}
|
|
61
73
|
>
|
|
62
|
-
|
|
74
|
+
{t("createSampleTask")}
|
|
63
75
|
</button>
|
|
64
76
|
</div>
|
|
65
77
|
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TranslationResources } from "@palettelab/sdk"
|
|
2
|
+
|
|
3
|
+
export const translations = {
|
|
4
|
+
en: {
|
|
5
|
+
title: "My Plugin",
|
|
6
|
+
greeting: "Hello, {{name}}! This plugin follows the Palette OS language.",
|
|
7
|
+
quickStart: "Quick Start",
|
|
8
|
+
editUi: "Edit frontend/src/index.tsx to build your UI",
|
|
9
|
+
editApi: "Edit backend/api/main.py to add API endpoints",
|
|
10
|
+
usePlatform: "Use usePlatform() to access the authenticated user and API",
|
|
11
|
+
useTasks: "Use usePluginTasks() to manage tasks",
|
|
12
|
+
useDataRooms: "Use usePluginDataRooms() to browse files",
|
|
13
|
+
tasks: "Tasks",
|
|
14
|
+
noTasks: "No tasks yet.",
|
|
15
|
+
createSampleTask: "Create Sample Task",
|
|
16
|
+
sampleTaskTitle: "Sample task from plugin",
|
|
17
|
+
taskCreated: "Task created!",
|
|
18
|
+
language: "Language",
|
|
19
|
+
},
|
|
20
|
+
ko: {
|
|
21
|
+
title: "내 플러그인",
|
|
22
|
+
greeting: "안녕하세요, {{name}}님! 이 플러그인은 Palette OS 언어를 따릅니다.",
|
|
23
|
+
quickStart: "빠른 시작",
|
|
24
|
+
editUi: "frontend/src/index.tsx를 수정해 UI를 만드세요",
|
|
25
|
+
editApi: "backend/api/main.py를 수정해 API 엔드포인트를 추가하세요",
|
|
26
|
+
usePlatform: "usePlatform()으로 인증 사용자와 API에 접근하세요",
|
|
27
|
+
useTasks: "usePluginTasks()로 작업을 관리하세요",
|
|
28
|
+
useDataRooms: "usePluginDataRooms()로 파일을 둘러보세요",
|
|
29
|
+
tasks: "작업",
|
|
30
|
+
noTasks: "아직 작업이 없습니다.",
|
|
31
|
+
createSampleTask: "샘플 작업 만들기",
|
|
32
|
+
sampleTaskTitle: "플러그인에서 만든 샘플 작업",
|
|
33
|
+
taskCreated: "작업이 생성되었습니다!",
|
|
34
|
+
language: "언어",
|
|
35
|
+
},
|
|
36
|
+
} satisfies TranslationResources
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react"
|
|
4
|
-
import { usePlatform } from "@palettelab/sdk"
|
|
4
|
+
import { usePlatform, usePluginTranslations } from "@palettelab/sdk"
|
|
5
5
|
import type { PluginComponentProps } from "@palettelab/sdk"
|
|
6
|
+
import { translations } from "./translations"
|
|
6
7
|
|
|
7
8
|
type Series = { label: string; value: number }
|
|
8
9
|
|
|
9
10
|
export default function DashboardWidget(_props: PluginComponentProps) {
|
|
10
11
|
const { apiFetch } = usePlatform()
|
|
12
|
+
const { t } = usePluginTranslations(translations)
|
|
11
13
|
const [data, setData] = useState<Series[]>([])
|
|
12
14
|
const [loading, setLoading] = useState(true)
|
|
13
15
|
|
|
@@ -22,9 +24,9 @@ export default function DashboardWidget(_props: PluginComponentProps) {
|
|
|
22
24
|
|
|
23
25
|
return (
|
|
24
26
|
<div className="p-6 space-y-4">
|
|
25
|
-
<h1 className="text-2xl font-bold">
|
|
27
|
+
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
|
26
28
|
{loading ? (
|
|
27
|
-
<p className="text-muted-foreground">
|
|
29
|
+
<p className="text-muted-foreground">{t("loading")}</p>
|
|
28
30
|
) : (
|
|
29
31
|
<div className="space-y-2">
|
|
30
32
|
{data.map((d) => (
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "A widget that exposes a dashboard data source and renders a chart from it.",
|
|
10
10
|
"icon": "ChartBar",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #06B6D4, #6366F1)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.11", "backend": "^0.1.0" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react"
|
|
4
|
-
import { usePlatform } from "@palettelab/sdk"
|
|
4
|
+
import { usePlatform, usePluginTranslations } from "@palettelab/sdk"
|
|
5
5
|
import type { PluginComponentProps } from "@palettelab/sdk"
|
|
6
|
+
import { translations } from "./translations"
|
|
6
7
|
|
|
7
8
|
type Note = { id: number; body: string }
|
|
8
9
|
|
|
@@ -96,6 +97,7 @@ const styles = `
|
|
|
96
97
|
|
|
97
98
|
export default function NotesApp(_props: PluginComponentProps) {
|
|
98
99
|
const { apiFetch, showToast, user } = usePlatform()
|
|
100
|
+
const { t } = usePluginTranslations(translations)
|
|
99
101
|
const [notes, setNotes] = useState<Note[]>([])
|
|
100
102
|
const [body, setBody] = useState("")
|
|
101
103
|
const [loading, setLoading] = useState(true)
|
|
@@ -105,7 +107,7 @@ export default function NotesApp(_props: PluginComponentProps) {
|
|
|
105
107
|
setLoading(true)
|
|
106
108
|
try {
|
|
107
109
|
const response = await apiFetch("/api/v1/plugins/my-db-plugin/notes")
|
|
108
|
-
if (!response.ok) throw new Error("
|
|
110
|
+
if (!response.ok) throw new Error(t("loadError"))
|
|
109
111
|
setNotes(await response.json())
|
|
110
112
|
} finally {
|
|
111
113
|
setLoading(false)
|
|
@@ -126,9 +128,9 @@ export default function NotesApp(_props: PluginComponentProps) {
|
|
|
126
128
|
headers: { "Content-Type": "application/json" },
|
|
127
129
|
body: JSON.stringify({ body: cleaned }),
|
|
128
130
|
})
|
|
129
|
-
if (!response.ok) throw new Error("
|
|
131
|
+
if (!response.ok) throw new Error(t("saveError"))
|
|
130
132
|
setBody("")
|
|
131
|
-
showToast("
|
|
133
|
+
showToast(t("saved"), "success")
|
|
132
134
|
await load()
|
|
133
135
|
} finally {
|
|
134
136
|
setSaving(false)
|
|
@@ -139,11 +141,10 @@ export default function NotesApp(_props: PluginComponentProps) {
|
|
|
139
141
|
<div className="notes-app">
|
|
140
142
|
<style>{styles}</style>
|
|
141
143
|
<main className="notes-shell">
|
|
142
|
-
<p className="notes-kicker">
|
|
143
|
-
<h1 className="notes-title">
|
|
144
|
+
<p className="notes-kicker">{t("kicker")}</p>
|
|
145
|
+
<h1 className="notes-title">{t("title")}</h1>
|
|
144
146
|
<p className="notes-copy">
|
|
145
|
-
|
|
146
|
-
SDK database session exposed as <code>ctx.db</code>.
|
|
147
|
+
{t("copy", { name: user.name })}
|
|
147
148
|
</p>
|
|
148
149
|
<section className="notes-form">
|
|
149
150
|
<input
|
|
@@ -153,7 +154,7 @@ export default function NotesApp(_props: PluginComponentProps) {
|
|
|
153
154
|
onKeyDown={(event) => {
|
|
154
155
|
if (event.key === "Enter") void add()
|
|
155
156
|
}}
|
|
156
|
-
placeholder="
|
|
157
|
+
placeholder={t("placeholder")}
|
|
157
158
|
/>
|
|
158
159
|
<button
|
|
159
160
|
type="button"
|
|
@@ -161,14 +162,14 @@ export default function NotesApp(_props: PluginComponentProps) {
|
|
|
161
162
|
disabled={saving || body.trim().length === 0}
|
|
162
163
|
className="notes-button"
|
|
163
164
|
>
|
|
164
|
-
{saving ? "
|
|
165
|
+
{saving ? t("saving") : t("add")}
|
|
165
166
|
</button>
|
|
166
167
|
</section>
|
|
167
168
|
<section className="notes-list">
|
|
168
169
|
{loading ? (
|
|
169
|
-
<p className="notes-empty">
|
|
170
|
+
<p className="notes-empty">{t("loading")}</p>
|
|
170
171
|
) : notes.length === 0 ? (
|
|
171
|
-
<p className="notes-empty">
|
|
172
|
+
<p className="notes-empty">{t("empty")}</p>
|
|
172
173
|
) : (
|
|
173
174
|
notes.map((note) => (
|
|
174
175
|
<p key={note.id} className="notes-item">
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TranslationResources } from "@palettelab/sdk"
|
|
2
|
+
|
|
3
|
+
export const translations = {
|
|
4
|
+
en: {
|
|
5
|
+
kicker: "Palette DB App",
|
|
6
|
+
title: "Org Notes",
|
|
7
|
+
copy: "Built for {{name}}. This app uses a Python backend and the Palette SDK database session exposed as ctx.db.",
|
|
8
|
+
placeholder: "New note...",
|
|
9
|
+
add: "Add Note",
|
|
10
|
+
saving: "Saving",
|
|
11
|
+
loading: "Loading notes...",
|
|
12
|
+
empty: "No notes yet.",
|
|
13
|
+
loadError: "Could not load notes",
|
|
14
|
+
saveError: "Could not save note",
|
|
15
|
+
saved: "Note saved",
|
|
16
|
+
},
|
|
17
|
+
ko: {
|
|
18
|
+
kicker: "Palette DB 앱",
|
|
19
|
+
title: "조직 노트",
|
|
20
|
+
copy: "{{name}}님을 위해 생성되었습니다. 이 앱은 Python 백엔드와 ctx.db로 제공되는 Palette SDK 데이터베이스 세션을 사용합니다.",
|
|
21
|
+
placeholder: "새 노트...",
|
|
22
|
+
add: "노트 추가",
|
|
23
|
+
saving: "저장 중",
|
|
24
|
+
loading: "노트를 불러오는 중...",
|
|
25
|
+
empty: "아직 노트가 없습니다.",
|
|
26
|
+
loadError: "노트를 불러올 수 없습니다",
|
|
27
|
+
saveError: "노트를 저장할 수 없습니다",
|
|
28
|
+
saved: "노트가 저장되었습니다",
|
|
29
|
+
},
|
|
30
|
+
} satisfies TranslationResources
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "Stores notes per organization with RLS-enforced isolation.",
|
|
10
10
|
"icon": "Database",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #8B5CF6, #EC4899)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.11", "backend": "^0.1.0" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useState } from "react"
|
|
4
|
-
import { usePlatform } from "@palettelab/sdk"
|
|
4
|
+
import { usePlatform, usePluginTranslations } from "@palettelab/sdk"
|
|
5
5
|
import type { PluginComponentProps } from "@palettelab/sdk"
|
|
6
|
+
import { translations } from "./translations"
|
|
6
7
|
|
|
7
8
|
export default function ExternalServiceWidget(_props: PluginComponentProps) {
|
|
8
9
|
const { apiFetch } = usePlatform()
|
|
10
|
+
const { t } = usePluginTranslations(translations)
|
|
9
11
|
const [result, setResult] = useState<string>("")
|
|
10
12
|
|
|
11
13
|
return (
|
|
12
14
|
<div className="p-6 space-y-3">
|
|
13
|
-
<h1 className="text-2xl font-bold">
|
|
15
|
+
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
|
14
16
|
<button
|
|
15
17
|
className="px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm"
|
|
16
18
|
onClick={async () => {
|
|
@@ -18,7 +20,7 @@ export default function ExternalServiceWidget(_props: PluginComponentProps) {
|
|
|
18
20
|
setResult(await r.text())
|
|
19
21
|
}}
|
|
20
22
|
>
|
|
21
|
-
|
|
23
|
+
{t("call")}
|
|
22
24
|
</button>
|
|
23
25
|
{result && <pre className="text-xs">{result}</pre>}
|
|
24
26
|
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TranslationResources } from "@palettelab/sdk"
|
|
2
|
+
|
|
3
|
+
export const translations = {
|
|
4
|
+
en: {
|
|
5
|
+
title: "External Service",
|
|
6
|
+
call: "Call api.example.com",
|
|
7
|
+
},
|
|
8
|
+
ko: {
|
|
9
|
+
title: "외부 서비스",
|
|
10
|
+
call: "api.example.com 호출",
|
|
11
|
+
},
|
|
12
|
+
} satisfies TranslationResources
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "Demonstrates declared external_network access and a scoped per-org config token.",
|
|
10
10
|
"icon": "CloudArrowUp",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #10B981, #06B6D4)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.11", "backend": "^0.1.0" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { usePlatform } from "@palettelab/sdk"
|
|
3
|
+
import { usePlatform, usePluginTranslations } from "@palettelab/sdk"
|
|
4
4
|
import type { PluginComponentProps } from "@palettelab/sdk"
|
|
5
|
+
import { translations } from "./translations"
|
|
5
6
|
|
|
6
7
|
export default function FrontendOnlyPlugin(_props: PluginComponentProps) {
|
|
7
8
|
const { user } = usePlatform()
|
|
9
|
+
const { t } = usePluginTranslations(translations)
|
|
10
|
+
|
|
8
11
|
return (
|
|
9
12
|
<div className="p-6">
|
|
10
|
-
<h1 className="text-2xl font-bold">
|
|
13
|
+
<h1 className="text-2xl font-bold">{t("title", { name: user.name })}</h1>
|
|
11
14
|
<p className="text-muted-foreground mt-2">
|
|
12
|
-
|
|
15
|
+
{t("body")}
|
|
13
16
|
</p>
|
|
14
17
|
</div>
|
|
15
18
|
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TranslationResources } from "@palettelab/sdk"
|
|
2
|
+
|
|
3
|
+
export const translations = {
|
|
4
|
+
en: {
|
|
5
|
+
title: "Hello, {{name}}",
|
|
6
|
+
body: "This plugin runs entirely in the browser sandbox and follows the Palette OS language.",
|
|
7
|
+
},
|
|
8
|
+
ko: {
|
|
9
|
+
title: "안녕하세요, {{name}}님",
|
|
10
|
+
body: "이 플러그인은 브라우저 샌드박스에서만 실행되며 Palette OS 언어를 따릅니다.",
|
|
11
|
+
},
|
|
12
|
+
} satisfies TranslationResources
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "A frontend-only plugin — renders inside the platform iframe sandbox with no backend.",
|
|
10
10
|
"icon": "Puzzle",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #6366F1, #8B5CF6)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.11" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|