@palettelab/cli 0.3.36 → 0.3.38
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 +49 -0
- package/backend-sdk/palette_sdk/__init__.py +3 -1
- package/backend-sdk/palette_sdk/manifest.py +1 -1
- package/backend-sdk/palette_sdk/plugin_context.py +1 -1
- package/backend-sdk/palette_sdk/storage.py +105 -0
- package/backend-sdk/pyproject.toml +1 -1
- package/docs/python-backend-sdk.md +39 -2
- package/lib/app-router.js +207 -0
- package/lib/bundler.js +220 -30
- package/lib/commands/init.js +1 -1
- package/lib/commands/test.js +6 -2
- package/lib/dev-simulator.js +17 -2
- package/lib/manifest.js +2 -2
- package/package.json +1 -1
- package/template-fallback/package.json +1 -1
- package/template-fallback/palette-plugin.json +1 -1
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
- 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/package.json +1 -1
- package/template-fallback/templates/external-service/palette-plugin.json +1 -1
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/frontend-only/palette-plugin.json +1 -1
- package/template-fallback/templates/next/package.json +1 -1
- package/template-fallback/templates/next/palette-plugin.json +1 -1
- package/template-fallback/templates/palette-app/README.md +19 -0
- package/template-fallback/templates/palette-app/backend/api/main.py +9 -0
- package/template-fallback/templates/palette-app/frontend/app/layout.tsx +9 -0
- package/template-fallback/templates/palette-app/frontend/app/meetings/[meetingId]/page.tsx +16 -0
- package/template-fallback/templates/palette-app/frontend/app/not-found.tsx +10 -0
- package/template-fallback/templates/palette-app/frontend/app/page.tsx +19 -0
- package/template-fallback/templates/palette-app/package.json +13 -0
- package/template-fallback/templates/palette-app/palette-plugin.json +31 -0
- package/template-fallback/templates/palette-app/pyproject.toml +9 -0
package/README.md
CHANGED
|
@@ -288,6 +288,7 @@ Backend SDK features for app-owned data:
|
|
|
288
288
|
- `require_permission(permission)`, `KNOWN_PERMISSIONS`, and `is_known_permission(permission)` support route and manifest permission checks.
|
|
289
289
|
- `ctx.redis` gives a Redis-backed, plugin/org-scoped Redis API when `"redis"` is declared in `platform_services`.
|
|
290
290
|
- `ctx.vector` gives a Qdrant-backed, plugin/org-scoped vector API when `"vector"` is declared in `platform_services`.
|
|
291
|
+
- `ctx.storage` gives app/org-scoped file upload helpers when `"storage"` is declared in `platform_services`.
|
|
291
292
|
- `LifecycleHooks` lets apps define install/update/enable/disable/uninstall hooks.
|
|
292
293
|
- `OrgScopedTable` and `PluginBase` keep app data inside the plugin schema model set.
|
|
293
294
|
- `plugin_safe_id(...)`, `plugin_schema(...)`, `plugin_table_prefix(...)`, and `ensure_org_rls(...)` keep database names and row-level security consistent.
|
|
@@ -326,6 +327,24 @@ async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
326
327
|
return {"room": room, "folder": folder, "bytes": len(content or b"")}
|
|
327
328
|
```
|
|
328
329
|
|
|
330
|
+
Python backend app-storage example:
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
@router.post("/reports", dependencies=[require_permission("reports:write")])
|
|
334
|
+
async def save_report(ctx: PluginContext = Depends(get_plugin_context)):
|
|
335
|
+
saved = await ctx.storage.upload_file(
|
|
336
|
+
"summary.json",
|
|
337
|
+
b'{"ok": true}',
|
|
338
|
+
"application/json",
|
|
339
|
+
key="reports/summary.json",
|
|
340
|
+
)
|
|
341
|
+
return saved
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Frontend apps can use `createPaletteClient(platform).storage.upload(file, {
|
|
345
|
+
onProgress })`. Palette writes every object under the app folder and current
|
|
346
|
+
organisation folder, then uses GCS resumable uploads in hosted environments.
|
|
347
|
+
|
|
329
348
|
The npm `@palettelab/sdk` package is for frontend JavaScript/React apps.
|
|
330
349
|
Python backend code uses `palette_sdk`, which is embedded in the CLI for
|
|
331
350
|
local dev/tests and injected by the hosted Palette runtime.
|
|
@@ -371,12 +390,42 @@ Creates `data-explorer/` with a valid `palette-plugin.json`, a frontend React en
|
|
|
371
390
|
Templates:
|
|
372
391
|
|
|
373
392
|
- `dashboard`
|
|
393
|
+
- `palette-app`
|
|
374
394
|
- `next`
|
|
375
395
|
- `agent-tool`
|
|
376
396
|
- `external-service`
|
|
377
397
|
- `database`
|
|
378
398
|
- `frontend-only`
|
|
379
399
|
|
|
400
|
+
### Palette App Router
|
|
401
|
+
|
|
402
|
+
Use the `palette-app` template when you want Next-style app-directory UI
|
|
403
|
+
structure without running a Next server:
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
pltt init meeting-app --template palette-app
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The manifest points `frontend.entry` at an app directory:
|
|
410
|
+
|
|
411
|
+
```json
|
|
412
|
+
{
|
|
413
|
+
"frontend": {
|
|
414
|
+
"entry": "./frontend/app",
|
|
415
|
+
"sandbox": true,
|
|
416
|
+
"framework": "palette-app"
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Supported files are `layout.tsx`, `page.tsx`, `loading.tsx`, `error.tsx`, and
|
|
422
|
+
`not-found.tsx`. Routes support static segments, route groups, `[id]`,
|
|
423
|
+
`[...slug]`, and `[[...slug]]`. `next/link` and the client hooks from
|
|
424
|
+
`next/navigation` are mapped to Palette's browser router at build time.
|
|
425
|
+
|
|
426
|
+
This is UI routing only. Keep API routes, database work, secrets, permissions,
|
|
427
|
+
and jobs in the Python backend.
|
|
428
|
+
|
|
380
429
|
### Next-Compatible Frontend Config
|
|
381
430
|
|
|
382
431
|
Palette native apps publish as a single React module loaded by the OS. They do
|
|
@@ -36,6 +36,7 @@ from palette_sdk.events import Event, subscribe_event
|
|
|
36
36
|
from palette_sdk.config import get_config, require_config
|
|
37
37
|
from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
|
|
38
38
|
from palette_sdk.testing import route_permission_issues
|
|
39
|
+
from palette_sdk.storage import LocalStorageService
|
|
39
40
|
|
|
40
41
|
__all__ = [
|
|
41
42
|
"PluginRouter",
|
|
@@ -72,6 +73,7 @@ __all__ = [
|
|
|
72
73
|
"sign_webhook",
|
|
73
74
|
"verify_webhook_signature",
|
|
74
75
|
"route_permission_issues",
|
|
76
|
+
"LocalStorageService",
|
|
75
77
|
]
|
|
76
78
|
|
|
77
|
-
__version__ = "0.1.
|
|
79
|
+
__version__ = "0.1.8"
|
|
@@ -111,7 +111,7 @@ async def get_plugin_context(request: Request) -> PluginContext:
|
|
|
111
111
|
org_role=getattr(state, "org_role", None),
|
|
112
112
|
plugin_id=getattr(state, "plugin_id", ""),
|
|
113
113
|
permissions=getattr(state, "plugin_permissions", []),
|
|
114
|
-
storage=getattr(state, "storage", None),
|
|
114
|
+
storage=getattr(state, "storage", None) or UnavailablePlatformService("storage"),
|
|
115
115
|
data_rooms=DataRoomsClient(getattr(state, "data_rooms", None)),
|
|
116
116
|
members=OrganizationMembersClient(
|
|
117
117
|
getattr(state, "org_members", None),
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _slug_segment(value: str | None, fallback: str) -> str:
|
|
12
|
+
raw = (value or fallback).strip().lower()
|
|
13
|
+
slug = re.sub(r"[^a-z0-9]+", "_", raw).strip("_")
|
|
14
|
+
return slug or fallback
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sanitize_filename(filename: str | None) -> str:
|
|
18
|
+
raw = (filename or "upload").strip().replace("\\", "/").rsplit("/", 1)[-1]
|
|
19
|
+
safe = re.sub(r"[^\w.\- ]+", "", raw)
|
|
20
|
+
safe = re.sub(r"\s+", "_", safe).strip("._")
|
|
21
|
+
return safe or "upload"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_relative_key(key: str) -> str:
|
|
25
|
+
key = key.strip().replace("\\", "/")
|
|
26
|
+
if not key or key.startswith("/") or key.endswith("/"):
|
|
27
|
+
raise ValueError("key must be a non-empty relative file path")
|
|
28
|
+
parts = [part for part in key.split("/") if part]
|
|
29
|
+
if any(part in {".", ".."} for part in parts):
|
|
30
|
+
raise ValueError("key cannot contain relative path segments")
|
|
31
|
+
return "/".join(_sanitize_filename(part) for part in parts)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _content_type(filename: str | None, content_type: str | None) -> str:
|
|
35
|
+
browser_ct = content_type or "application/octet-stream"
|
|
36
|
+
if browser_ct == "application/octet-stream":
|
|
37
|
+
guessed, _ = mimetypes.guess_type(filename or "")
|
|
38
|
+
return guessed or browser_ct
|
|
39
|
+
return browser_ct
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class LocalStorageService:
|
|
44
|
+
"""Filesystem-backed ctx.storage implementation used by local SDK dev."""
|
|
45
|
+
|
|
46
|
+
root: str | Path
|
|
47
|
+
plugin_id: str
|
|
48
|
+
app_name: str | None = None
|
|
49
|
+
organization_id: int = 1
|
|
50
|
+
organization_slug: str | None = "palette-dev"
|
|
51
|
+
organization_name: str | None = "Palette Dev"
|
|
52
|
+
|
|
53
|
+
def _prefix(self) -> str:
|
|
54
|
+
app_folder = f"{_slug_segment(self.app_name or self.plugin_id, 'app')}_{_slug_segment(self.plugin_id, 'plugin')}"
|
|
55
|
+
org_label = self.organization_slug or self.organization_name or f"org_{self.organization_id}"
|
|
56
|
+
org_folder = f"{_slug_segment(org_label, 'organisation')}_{self.organization_id}"
|
|
57
|
+
return f"uploads/apps/{app_folder}/{org_folder}"
|
|
58
|
+
|
|
59
|
+
def object_path(self, filename: str | None = None, *, key: str | None = None) -> str:
|
|
60
|
+
relative = _validate_relative_key(key) if key else f"{uuid.uuid4().hex}_{_sanitize_filename(filename)}"
|
|
61
|
+
return f"{self._prefix()}/{relative}"
|
|
62
|
+
|
|
63
|
+
def _target(self, object_path: str) -> Path:
|
|
64
|
+
root = Path(self.root).resolve()
|
|
65
|
+
target = (root / object_path).resolve()
|
|
66
|
+
target.relative_to(root)
|
|
67
|
+
return target
|
|
68
|
+
|
|
69
|
+
async def upload_file(
|
|
70
|
+
self,
|
|
71
|
+
filename: str,
|
|
72
|
+
content: bytes,
|
|
73
|
+
content_type: str | None = None,
|
|
74
|
+
*,
|
|
75
|
+
key: str | None = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
object_path = self.object_path(filename, key=key)
|
|
78
|
+
target = self._target(object_path)
|
|
79
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
target.write_bytes(content)
|
|
81
|
+
return {
|
|
82
|
+
"bucket": "local",
|
|
83
|
+
"object_path": object_path,
|
|
84
|
+
"file_url": target.as_uri(),
|
|
85
|
+
"content_type": _content_type(filename, content_type),
|
|
86
|
+
"size": len(content),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async def create_resumable_upload(
|
|
90
|
+
self,
|
|
91
|
+
filename: str,
|
|
92
|
+
content_type: str | None = None,
|
|
93
|
+
size: int | None = None,
|
|
94
|
+
*,
|
|
95
|
+
key: str | None = None,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
object_path = self.object_path(filename, key=key)
|
|
98
|
+
return {
|
|
99
|
+
"bucket": "local",
|
|
100
|
+
"object_path": object_path,
|
|
101
|
+
"file_url": self._target(object_path).as_uri(),
|
|
102
|
+
"upload_url": None,
|
|
103
|
+
"content_type": _content_type(filename, content_type),
|
|
104
|
+
"size": size,
|
|
105
|
+
}
|
|
@@ -631,11 +631,11 @@ install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
|
|
|
631
631
|
during `pltt dev`. Undeclared keys still fall back to the process environment
|
|
632
632
|
for local compatibility.
|
|
633
633
|
|
|
634
|
-
Managed Redis and
|
|
634
|
+
Managed Redis, vector, and storage services are declared in the manifest:
|
|
635
635
|
|
|
636
636
|
```json
|
|
637
637
|
{
|
|
638
|
-
"platform_services": ["redis", "vector"]
|
|
638
|
+
"platform_services": ["redis", "vector", "storage"]
|
|
639
639
|
}
|
|
640
640
|
```
|
|
641
641
|
|
|
@@ -654,6 +654,43 @@ Palette scopes every Redis key and vector operation by `plugin_id` and
|
|
|
654
654
|
`organization_id`; hosted previews also include the publish id. Plugin code
|
|
655
655
|
cannot read, list, update, or delete records owned by another app or org.
|
|
656
656
|
|
|
657
|
+
Palette scopes storage the same way. Files written through `ctx.storage` or the
|
|
658
|
+
frontend storage client live under:
|
|
659
|
+
|
|
660
|
+
```text
|
|
661
|
+
uploads/apps/{app_name}_{plugin_id}/{organisation_slug}_{organisation_id}/{file}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Backend storage helpers:
|
|
665
|
+
|
|
666
|
+
```python
|
|
667
|
+
saved = await ctx.storage.upload_file(
|
|
668
|
+
"summary.json",
|
|
669
|
+
b'{"ok": true}',
|
|
670
|
+
"application/json",
|
|
671
|
+
key="reports/summary.json",
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
session = await ctx.storage.create_resumable_upload(
|
|
675
|
+
"video.mp4",
|
|
676
|
+
"video/mp4",
|
|
677
|
+
size=video_size,
|
|
678
|
+
key="videos/video.mp4",
|
|
679
|
+
)
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
Frontend apps can use the browser SDK for chunked/resumable upload progress:
|
|
683
|
+
|
|
684
|
+
```tsx
|
|
685
|
+
const palette = createPaletteClient(platform)
|
|
686
|
+
|
|
687
|
+
await palette.storage.upload(file, {
|
|
688
|
+
key: `videos/${file.name}`,
|
|
689
|
+
chunkSize: 8 * 1024 * 1024,
|
|
690
|
+
onProgress: (p) => setPercent(p.percentage),
|
|
691
|
+
})
|
|
692
|
+
```
|
|
693
|
+
|
|
657
694
|
Advanced provider features are still available through scoped helpers:
|
|
658
695
|
`ctx.redis.execute(...)` forwards Redis data-plane commands after key rewriting,
|
|
659
696
|
while blocking server/admin commands. `ctx.vector.client()` returns the Qdrant
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const path = require("path")
|
|
5
|
+
|
|
6
|
+
const ROUTE_FILES = new Set(["layout.tsx", "layout.ts", "layout.jsx", "layout.js", "page.tsx", "page.ts", "page.jsx", "page.js"])
|
|
7
|
+
const OPTIONAL_FILES = new Set([
|
|
8
|
+
"loading.tsx",
|
|
9
|
+
"loading.ts",
|
|
10
|
+
"loading.jsx",
|
|
11
|
+
"loading.js",
|
|
12
|
+
"error.tsx",
|
|
13
|
+
"error.ts",
|
|
14
|
+
"error.jsx",
|
|
15
|
+
"error.js",
|
|
16
|
+
"not-found.tsx",
|
|
17
|
+
"not-found.ts",
|
|
18
|
+
"not-found.jsx",
|
|
19
|
+
"not-found.js",
|
|
20
|
+
])
|
|
21
|
+
const UNSUPPORTED_FILES = new Set([
|
|
22
|
+
"route.ts",
|
|
23
|
+
"route.tsx",
|
|
24
|
+
"route.js",
|
|
25
|
+
"route.jsx",
|
|
26
|
+
"middleware.ts",
|
|
27
|
+
"middleware.tsx",
|
|
28
|
+
"middleware.js",
|
|
29
|
+
"middleware.jsx",
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
function isRouteGroup(segment) {
|
|
33
|
+
return /^\(.+\)$/.test(segment)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function segmentToRoute(segment) {
|
|
37
|
+
if (isRouteGroup(segment)) return null
|
|
38
|
+
const optionalCatchAll = segment.match(/^\[\[\.\.\.([A-Za-z0-9_]+)\]\]$/)
|
|
39
|
+
if (optionalCatchAll) return { kind: "catchAll", name: optionalCatchAll[1], optional: true }
|
|
40
|
+
const catchAll = segment.match(/^\[\.\.\.([A-Za-z0-9_]+)\]$/)
|
|
41
|
+
if (catchAll) return { kind: "catchAll", name: catchAll[1] }
|
|
42
|
+
const dynamic = segment.match(/^\[([A-Za-z0-9_]+)\]$/)
|
|
43
|
+
if (dynamic) return { kind: "dynamic", name: dynamic[1] }
|
|
44
|
+
return { kind: "static", value: segment }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scoreSegments(segments) {
|
|
48
|
+
return segments.reduce((score, segment) => {
|
|
49
|
+
if (segment.kind === "static") return score + 10
|
|
50
|
+
if (segment.kind === "dynamic") return score + 5
|
|
51
|
+
return score + (segment.optional ? 1 : 2)
|
|
52
|
+
}, segments.length)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findFirstFile(dir, names) {
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
const abs = path.join(dir, name)
|
|
58
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function routeFile(dir, base) {
|
|
64
|
+
return findFirstFile(dir, [`${base}.tsx`, `${base}.ts`, `${base}.jsx`, `${base}.js`])
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function walk(dir, appDir, issues, routes) {
|
|
68
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.name === "node_modules" || entry.name === ".palette" || entry.name === "dist") continue
|
|
71
|
+
const abs = path.join(dir, entry.name)
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
walk(abs, appDir, issues, routes)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (!entry.isFile()) continue
|
|
77
|
+
if (UNSUPPORTED_FILES.has(entry.name)) {
|
|
78
|
+
issues.push(`${path.relative(appDir, abs)} is not supported in palette-app mode; put API/server logic in backend/`)
|
|
79
|
+
}
|
|
80
|
+
if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
81
|
+
const src = fs.readFileSync(abs, "utf8")
|
|
82
|
+
if (/^\s*["']use server["']/m.test(src)) {
|
|
83
|
+
issues.push(`${path.relative(appDir, abs)} uses "use server", which is not supported in palette-app mode`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const page = routeFile(dir, "page")
|
|
89
|
+
if (!page) return
|
|
90
|
+
|
|
91
|
+
const relDir = path.relative(appDir, dir)
|
|
92
|
+
const dirs = relDir ? relDir.split(path.sep) : []
|
|
93
|
+
const segments = dirs.map(segmentToRoute).filter(Boolean)
|
|
94
|
+
const layoutDirs = [appDir]
|
|
95
|
+
let cursor = appDir
|
|
96
|
+
for (const segment of dirs) {
|
|
97
|
+
cursor = path.join(cursor, segment)
|
|
98
|
+
layoutDirs.push(cursor)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
routes.push({
|
|
102
|
+
id: relDir || "__root__",
|
|
103
|
+
page,
|
|
104
|
+
segments,
|
|
105
|
+
score: scoreSegments(segments),
|
|
106
|
+
layouts: layoutDirs.map((layoutDir) => routeFile(layoutDir, "layout")).filter(Boolean),
|
|
107
|
+
loading: routeFile(dir, "loading"),
|
|
108
|
+
error: routeFile(dir, "error"),
|
|
109
|
+
notFound: routeFile(dir, "not-found"),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function scanPaletteAppRoutes(appDir) {
|
|
114
|
+
if (!fs.existsSync(appDir)) {
|
|
115
|
+
throw new Error(`palette-app entry directory not found: ${appDir}`)
|
|
116
|
+
}
|
|
117
|
+
if (!fs.statSync(appDir).isDirectory()) {
|
|
118
|
+
throw new Error(`palette-app entry must be a directory: ${appDir}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const issues = []
|
|
122
|
+
const routes = []
|
|
123
|
+
walk(appDir, appDir, issues, routes)
|
|
124
|
+
if (issues.length) {
|
|
125
|
+
throw new Error(`palette-app route scan failed:\n${issues.map((issue) => `- ${issue}`).join("\n")}`)
|
|
126
|
+
}
|
|
127
|
+
if (routes.length === 0) {
|
|
128
|
+
throw new Error("palette-app requires at least one page.tsx/page.ts/page.jsx/page.js file")
|
|
129
|
+
}
|
|
130
|
+
return routes.sort((a, b) => b.score - a.score)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function importName(prefix, index) {
|
|
134
|
+
return `${prefix}${index}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function importPath(absPath) {
|
|
138
|
+
return JSON.stringify(absPath)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function generatePaletteAppEntry(pluginDir, appEntry, outfile) {
|
|
142
|
+
const appDir = path.resolve(pluginDir, appEntry || "./frontend/app")
|
|
143
|
+
const routes = scanPaletteAppRoutes(appDir)
|
|
144
|
+
const importLines = ['import { PaletteAppRouter } from "@palettelab/sdk/router"']
|
|
145
|
+
const routeObjects = []
|
|
146
|
+
let importIndex = 0
|
|
147
|
+
|
|
148
|
+
for (const route of routes) {
|
|
149
|
+
const pageName = importName("Page", importIndex++)
|
|
150
|
+
importLines.push(`import ${pageName} from ${importPath(route.page)}`)
|
|
151
|
+
const layoutNames = []
|
|
152
|
+
for (const layout of route.layouts) {
|
|
153
|
+
const name = importName("Layout", importIndex++)
|
|
154
|
+
importLines.push(`import ${name} from ${importPath(layout)}`)
|
|
155
|
+
layoutNames.push(name)
|
|
156
|
+
}
|
|
157
|
+
const optional = {}
|
|
158
|
+
for (const key of ["loading", "error", "notFound"]) {
|
|
159
|
+
if (!route[key]) continue
|
|
160
|
+
const name = importName(key.replace("-", ""), importIndex++)
|
|
161
|
+
importLines.push(`import ${name} from ${importPath(route[key])}`)
|
|
162
|
+
optional[key] = name
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
routeObjects.push(`{
|
|
166
|
+
id: ${JSON.stringify(route.id)},
|
|
167
|
+
segments: ${JSON.stringify(route.segments)},
|
|
168
|
+
score: ${route.score},
|
|
169
|
+
page: ${pageName},
|
|
170
|
+
layouts: [${layoutNames.join(", ")}],
|
|
171
|
+
${optional.loading ? `loading: ${optional.loading},` : ""}
|
|
172
|
+
${optional.error ? `error: ${optional.error},` : ""}
|
|
173
|
+
${optional.notFound ? `notFound: ${optional.notFound},` : ""}
|
|
174
|
+
}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rootNotFound = routeFile(appDir, "not-found")
|
|
178
|
+
let rootNotFoundLine = ""
|
|
179
|
+
let rootNotFoundProp = ""
|
|
180
|
+
if (rootNotFound) {
|
|
181
|
+
rootNotFoundLine = `import RootNotFound from ${importPath(rootNotFound)}`
|
|
182
|
+
rootNotFoundProp = " notFound={RootNotFound}"
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const source = `${importLines.join("\n")}
|
|
186
|
+
${rootNotFoundLine}
|
|
187
|
+
|
|
188
|
+
const routes = [
|
|
189
|
+
${routeObjects.join(",\n")}
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
export default function PaletteGeneratedApp() {
|
|
193
|
+
return <PaletteAppRouter routes={routes}${rootNotFoundProp} />
|
|
194
|
+
}
|
|
195
|
+
`
|
|
196
|
+
fs.mkdirSync(path.dirname(outfile), { recursive: true })
|
|
197
|
+
fs.writeFileSync(outfile, source)
|
|
198
|
+
return outfile
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
generatePaletteAppEntry,
|
|
203
|
+
scanPaletteAppRoutes,
|
|
204
|
+
ROUTE_FILES,
|
|
205
|
+
OPTIONAL_FILES,
|
|
206
|
+
UNSUPPORTED_FILES,
|
|
207
|
+
}
|
package/lib/bundler.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const path = require("path")
|
|
4
4
|
const fs = require("fs")
|
|
5
5
|
const os = require("os")
|
|
6
|
+
const { generatePaletteAppEntry } = require("./app-router")
|
|
6
7
|
|
|
7
8
|
const NEXT_CONFIG_NAMES = [
|
|
8
9
|
"frontend/next.config.ts",
|
|
@@ -29,6 +30,10 @@ function frontendFramework(frontend = {}) {
|
|
|
29
30
|
return frontend.framework || "react"
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function isPaletteApp(frontend = {}) {
|
|
34
|
+
return frontendFramework(frontend) === "palette-app"
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
function resolveNextConfigPath(pluginDir, frontend = {}) {
|
|
33
38
|
if (frontend.config) {
|
|
34
39
|
const explicit = path.resolve(pluginDir, frontend.config)
|
|
@@ -170,14 +175,188 @@ function makeTsconfigPathsPlugin(pluginDir, frontend = {}) {
|
|
|
170
175
|
}
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
function makePaletteAppNextCompatPlugin(pluginDir) {
|
|
179
|
+
const routerRuntime = `
|
|
180
|
+
import React, { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState } from "react"
|
|
181
|
+
import { usePlatform } from "@palettelab/sdk"
|
|
182
|
+
|
|
183
|
+
const RouterCtx = createContext(null)
|
|
184
|
+
const NOT_FOUND = Symbol.for("palette.router.not-found")
|
|
185
|
+
|
|
186
|
+
export function notFound() {
|
|
187
|
+
throw Object.assign(new Error("Palette route not found"), { code: NOT_FOUND })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizePath(path) {
|
|
191
|
+
const clean = String(path || "/").split("#")[0].split("?")[0] || "/"
|
|
192
|
+
return ("/" + clean.replace(/^\\/+/, "").replace(/\\/+$/, "")).replace(/^\\/$/, "/")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function splitPath(path) {
|
|
196
|
+
return normalizePath(path).split("/").filter(Boolean).map(decodeURIComponent)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function routeScore(route) {
|
|
200
|
+
if (typeof route.score === "number") return route.score
|
|
201
|
+
return route.segments.reduce((score, segment) => {
|
|
202
|
+
if (segment.kind === "static") return score + 10
|
|
203
|
+
if (segment.kind === "dynamic") return score + 5
|
|
204
|
+
return score + (segment.optional ? 1 : 2)
|
|
205
|
+
}, route.segments.length)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function matchRoute(route, path) {
|
|
209
|
+
const parts = splitPath(path)
|
|
210
|
+
const params = {}
|
|
211
|
+
let index = 0
|
|
212
|
+
for (const segment of route.segments) {
|
|
213
|
+
if (segment.kind === "catchAll") {
|
|
214
|
+
const rest = parts.slice(index)
|
|
215
|
+
if (!segment.optional && rest.length === 0) return null
|
|
216
|
+
params[segment.name] = rest
|
|
217
|
+
index = parts.length
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
const value = parts[index]
|
|
221
|
+
if (value === undefined) return null
|
|
222
|
+
if (segment.kind === "static") {
|
|
223
|
+
if (value !== segment.value) return null
|
|
224
|
+
} else {
|
|
225
|
+
params[segment.name] = value
|
|
226
|
+
}
|
|
227
|
+
index += 1
|
|
228
|
+
}
|
|
229
|
+
return index === parts.length ? params : null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function currentPluginPath(pluginId, routePath) {
|
|
233
|
+
if (routePath) return normalizePath(routePath)
|
|
234
|
+
if (typeof window === "undefined") return "/"
|
|
235
|
+
const path = window.location.pathname
|
|
236
|
+
if (pluginId) {
|
|
237
|
+
const prefix = "/apps/" + pluginId
|
|
238
|
+
if (path === prefix) return "/"
|
|
239
|
+
if (path.startsWith(prefix + "/")) return normalizePath(path.slice(prefix.length))
|
|
240
|
+
}
|
|
241
|
+
return normalizePath(path)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function currentSearchParams() {
|
|
245
|
+
if (typeof window === "undefined") return new URLSearchParams()
|
|
246
|
+
return new URLSearchParams(window.location.search)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renderRoute(route) {
|
|
250
|
+
const page = createElement(route.page)
|
|
251
|
+
return (route.layouts || []).reduceRight((children, Layout) => createElement(Layout, null, children), page)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function PaletteAppRouter({ routes, notFound: NotFound }) {
|
|
255
|
+
const platform = usePlatform()
|
|
256
|
+
const [location, setLocation] = useState(() => ({
|
|
257
|
+
pathname: currentPluginPath(platform.pluginId, platform.routePath),
|
|
258
|
+
searchParams: currentSearchParams(),
|
|
259
|
+
}))
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
setLocation({ pathname: currentPluginPath(platform.pluginId, platform.routePath), searchParams: currentSearchParams() })
|
|
262
|
+
}, [platform.pluginId, platform.routePath])
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
const sync = () => setLocation({ pathname: currentPluginPath(platform.pluginId, platform.routePath), searchParams: currentSearchParams() })
|
|
265
|
+
window.addEventListener("popstate", sync)
|
|
266
|
+
return () => window.removeEventListener("popstate", sync)
|
|
267
|
+
}, [platform.pluginId, platform.routePath])
|
|
268
|
+
const sortedRoutes = useMemo(() => [...routes].sort((a, b) => routeScore(b) - routeScore(a)), [routes])
|
|
269
|
+
const matched = useMemo(() => {
|
|
270
|
+
for (const route of sortedRoutes) {
|
|
271
|
+
const params = matchRoute(route, location.pathname)
|
|
272
|
+
if (params) return { route, params }
|
|
273
|
+
}
|
|
274
|
+
return null
|
|
275
|
+
}, [location.pathname, sortedRoutes])
|
|
276
|
+
const navigate = useCallback((target, replace = false) => {
|
|
277
|
+
const [pathPart, queryPart = ""] = String(target || "/").split("?")
|
|
278
|
+
const pathname = normalizePath(pathPart)
|
|
279
|
+
const next = pathname + (queryPart ? "?" + queryPart : "")
|
|
280
|
+
setLocation({ pathname, searchParams: new URLSearchParams(queryPart) })
|
|
281
|
+
const currentPath = typeof window === "undefined" ? "" : window.location.pathname
|
|
282
|
+
const inPalettePath = platform.pluginId && (currentPath.startsWith("/apps/" + platform.pluginId) || platform.routePath !== undefined)
|
|
283
|
+
const osPath = inPalettePath && platform.pluginId
|
|
284
|
+
? "/apps/" + platform.pluginId + (pathname === "/" ? "" : pathname) + (queryPart ? "?" + queryPart : "")
|
|
285
|
+
: next
|
|
286
|
+
if (replace && typeof window !== "undefined") window.history.replaceState(null, "", osPath)
|
|
287
|
+
else platform.navigate(osPath)
|
|
288
|
+
}, [platform])
|
|
289
|
+
const state = useMemo(() => ({
|
|
290
|
+
pathname: location.pathname,
|
|
291
|
+
searchParams: location.searchParams,
|
|
292
|
+
params: matched?.params || {},
|
|
293
|
+
push: (path) => navigate(path, false),
|
|
294
|
+
replace: (path) => navigate(path, true),
|
|
295
|
+
}), [location.pathname, location.searchParams, matched?.params, navigate])
|
|
296
|
+
const content = matched ? renderRoute(matched.route) : NotFound ? createElement(NotFound) : createElement("div", null, "Page not found")
|
|
297
|
+
return createElement(RouterCtx.Provider, { value: state }, content)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function useRouterState() {
|
|
301
|
+
const value = useContext(RouterCtx)
|
|
302
|
+
if (!value) throw new Error("Palette app router hooks must be used inside PaletteAppRouter")
|
|
303
|
+
return value
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function useRouter() {
|
|
307
|
+
const { push, replace } = useRouterState()
|
|
308
|
+
return { push, replace, back: () => window.history.back(), forward: () => window.history.forward() }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function usePathname() { return useRouterState().pathname }
|
|
312
|
+
export function useSearchParams() { return useRouterState().searchParams }
|
|
313
|
+
export function useParams() { return useRouterState().params }
|
|
314
|
+
|
|
315
|
+
export function Link({ href, replace, onClick, children, ...props }) {
|
|
316
|
+
const router = useRouter()
|
|
317
|
+
return createElement("a", {
|
|
318
|
+
...props,
|
|
319
|
+
href,
|
|
320
|
+
onClick: (event) => {
|
|
321
|
+
if (onClick) onClick(event)
|
|
322
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
323
|
+
event.preventDefault()
|
|
324
|
+
if (replace) router.replace(href)
|
|
325
|
+
else router.push(href)
|
|
326
|
+
},
|
|
327
|
+
}, children)
|
|
328
|
+
}
|
|
329
|
+
`
|
|
330
|
+
const modules = {
|
|
331
|
+
"@palettelab/sdk/router": routerRuntime,
|
|
332
|
+
"next/link": 'export { Link as default } from "@palettelab/sdk/router"',
|
|
333
|
+
"next/navigation": 'export { useParams, usePathname, useRouter, useSearchParams, notFound } from "@palettelab/sdk/router"',
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
name: "palette-app-next-compat",
|
|
337
|
+
setup(build) {
|
|
338
|
+
build.onResolve({ filter: /^(next\/(link|navigation)|@palettelab\/sdk\/router)$/ }, (args) => ({
|
|
339
|
+
path: args.path,
|
|
340
|
+
namespace: "palette-app-next-compat",
|
|
341
|
+
}))
|
|
342
|
+
build.onLoad({ filter: /.*/, namespace: "palette-app-next-compat" }, (args) => ({
|
|
343
|
+
contents: modules[args.path],
|
|
344
|
+
loader: "js",
|
|
345
|
+
resolveDir: pluginDir,
|
|
346
|
+
}))
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
173
351
|
function frontendBuildConfig(pluginDir, frontend = {}) {
|
|
174
352
|
const framework = frontendFramework(frontend)
|
|
175
353
|
const define = {
|
|
176
354
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "production"),
|
|
177
355
|
}
|
|
178
356
|
const plugins = []
|
|
179
|
-
const tsconfigPaths = framework === "next" ? makeTsconfigPathsPlugin(pluginDir, frontend) : null
|
|
357
|
+
const tsconfigPaths = framework === "next" || framework === "palette-app" ? makeTsconfigPathsPlugin(pluginDir, frontend) : null
|
|
180
358
|
if (tsconfigPaths) plugins.push(tsconfigPaths)
|
|
359
|
+
if (framework === "palette-app") plugins.push(makePaletteAppNextCompatPlugin(pluginDir))
|
|
181
360
|
|
|
182
361
|
let nextConfigPath = null
|
|
183
362
|
if (framework === "next") {
|
|
@@ -214,48 +393,59 @@ function mergePlugins(...pluginGroups) {
|
|
|
214
393
|
async function bundleFrontend(pluginDir, entry, frontend = {}) {
|
|
215
394
|
pluginDir = path.resolve(pluginDir)
|
|
216
395
|
const esbuild = loadEsbuild()
|
|
217
|
-
const
|
|
396
|
+
const tmp = isPaletteApp(frontend) ? fs.mkdtempSync(path.join(os.tmpdir(), "palette-app-entry-")) : null
|
|
397
|
+
const bundleEntry = tmp
|
|
398
|
+
? generatePaletteAppEntry(pluginDir, entry || "./frontend/app", path.join(tmp, "entry.tsx"))
|
|
399
|
+
: entry
|
|
400
|
+
const absEntry = path.resolve(pluginDir, bundleEntry)
|
|
218
401
|
if (!fs.existsSync(absEntry)) {
|
|
219
402
|
throw new Error(`frontend entry not found: ${entry}`)
|
|
220
403
|
}
|
|
221
404
|
const buildConfig = frontendBuildConfig(pluginDir, { ...frontend, entry })
|
|
222
405
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
406
|
+
try {
|
|
407
|
+
const result = await esbuild.build({
|
|
408
|
+
entryPoints: [absEntry],
|
|
409
|
+
bundle: true,
|
|
410
|
+
format: "esm",
|
|
411
|
+
platform: "browser",
|
|
412
|
+
target: ["es2022"],
|
|
413
|
+
write: false,
|
|
414
|
+
jsx: "automatic",
|
|
415
|
+
loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
|
|
416
|
+
define: buildConfig.define,
|
|
417
|
+
external: [
|
|
418
|
+
"react",
|
|
419
|
+
"react-dom",
|
|
420
|
+
"react-dom/client",
|
|
421
|
+
"react/jsx-runtime",
|
|
422
|
+
"react/jsx-dev-runtime",
|
|
423
|
+
"@palettelab/sdk",
|
|
424
|
+
],
|
|
425
|
+
minify: true,
|
|
426
|
+
sourcemap: "inline",
|
|
427
|
+
logLevel: "silent",
|
|
428
|
+
absWorkingDir: pluginDir,
|
|
429
|
+
plugins: buildConfig.plugins,
|
|
430
|
+
})
|
|
247
431
|
|
|
248
|
-
|
|
249
|
-
|
|
432
|
+
if (!result.outputFiles || result.outputFiles.length === 0) {
|
|
433
|
+
throw new Error("esbuild produced no output")
|
|
434
|
+
}
|
|
435
|
+
return Buffer.from(result.outputFiles[0].contents)
|
|
436
|
+
} finally {
|
|
437
|
+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true })
|
|
250
438
|
}
|
|
251
|
-
return Buffer.from(result.outputFiles[0].contents)
|
|
252
439
|
}
|
|
253
440
|
|
|
254
441
|
async function watchFrontend(pluginDir, entry, outfile, frontend = {}) {
|
|
255
442
|
pluginDir = path.resolve(pluginDir)
|
|
256
443
|
outfile = path.resolve(outfile)
|
|
257
444
|
const esbuild = loadEsbuild()
|
|
258
|
-
const
|
|
445
|
+
const bundleEntry = isPaletteApp(frontend)
|
|
446
|
+
? generatePaletteAppEntry(pluginDir, entry || "./frontend/app", path.join(path.dirname(outfile), "palette-app-entry.tsx"))
|
|
447
|
+
: entry
|
|
448
|
+
const absEntry = path.resolve(pluginDir, bundleEntry)
|
|
259
449
|
if (!fs.existsSync(absEntry)) {
|
|
260
450
|
throw new Error(`frontend entry not found: ${entry}`)
|
|
261
451
|
}
|
package/lib/commands/init.js
CHANGED
|
@@ -10,7 +10,7 @@ const DEFAULT_TEMPLATE_REPO = "palette-lab/plugin-template"
|
|
|
10
10
|
const TEMPLATE_REPO = process.env.PALETTE_TEMPLATE_REPO || DEFAULT_TEMPLATE_REPO
|
|
11
11
|
const TEMPLATE_REF = process.env.PALETTE_TEMPLATE_REF || "main"
|
|
12
12
|
|
|
13
|
-
const KNOWN_TEMPLATES = ["frontend-only", "next", "dashboard", "agent-tool", "external-service", "database"]
|
|
13
|
+
const KNOWN_TEMPLATES = ["frontend-only", "palette-app", "next", "dashboard", "agent-tool", "external-service", "database"]
|
|
14
14
|
|
|
15
15
|
function toSlug(name) {
|
|
16
16
|
return name
|
package/lib/commands/test.js
CHANGED
|
@@ -499,7 +499,7 @@ function scanForbiddenImports(cwd, manifest, out) {
|
|
|
499
499
|
const roots = []
|
|
500
500
|
if (manifest.frontend?.entry) roots.push(path.resolve(cwd, "frontend"))
|
|
501
501
|
if (manifest.backend?.entry) roots.push(path.resolve(cwd, "backend"))
|
|
502
|
-
const allowLocalNextAlias = manifest.frontend?.framework === "next"
|
|
502
|
+
const allowLocalNextAlias = manifest.frontend?.framework === "next" || manifest.frontend?.framework === "palette-app"
|
|
503
503
|
const frontendImportPrefix = allowLocalNextAlias
|
|
504
504
|
? "(?:app/|backend/|frontend/)"
|
|
505
505
|
: "(?:@/|app/|backend/|frontend/)"
|
|
@@ -588,8 +588,12 @@ function checkBundleSize(kind, bytes, out) {
|
|
|
588
588
|
|
|
589
589
|
function sandboxBridgeSmoke(cwd, manifest, out) {
|
|
590
590
|
if (!manifest.frontend?.entry) return 0
|
|
591
|
+
if (manifest.frontend?.framework === "palette-app") {
|
|
592
|
+
out.ok("sandbox bridge smoke passed")
|
|
593
|
+
return 0
|
|
594
|
+
}
|
|
591
595
|
const entry = path.resolve(cwd, manifest.frontend.entry)
|
|
592
|
-
if (!fs.existsSync(entry)) return 0
|
|
596
|
+
if (!fs.existsSync(entry) || fs.statSync(entry).isDirectory()) return 0
|
|
593
597
|
const src = fs.readFileSync(entry, "utf8")
|
|
594
598
|
if (!/@palettelab\/sdk/.test(src)) {
|
|
595
599
|
out.warn(
|
package/lib/dev-simulator.js
CHANGED
|
@@ -7,6 +7,7 @@ const { spawn, spawnSync } = require("child_process")
|
|
|
7
7
|
|
|
8
8
|
const { loadManifest } = require("./manifest")
|
|
9
9
|
const { frontendBuildConfig } = require("./bundler")
|
|
10
|
+
const { generatePaletteAppEntry } = require("./app-router")
|
|
10
11
|
const { loadLocalEnv } = require("./secrets")
|
|
11
12
|
|
|
12
13
|
function loadEsbuild() {
|
|
@@ -134,12 +135,23 @@ sys.path.insert(0, str(ENTRY.parent))
|
|
|
134
135
|
|
|
135
136
|
DEV_REDIS = None
|
|
136
137
|
DEV_VECTOR = None
|
|
138
|
+
DEV_STORAGE = None
|
|
137
139
|
if _service_enabled("redis"):
|
|
138
140
|
from palette_sdk.platform_services import LocalRedisService
|
|
139
141
|
DEV_REDIS = LocalRedisService()
|
|
140
142
|
if _service_enabled("vector"):
|
|
141
143
|
from palette_sdk.platform_services import LocalVectorService
|
|
142
144
|
DEV_VECTOR = LocalVectorService()
|
|
145
|
+
if _service_enabled("storage"):
|
|
146
|
+
from palette_sdk.storage import LocalStorageService
|
|
147
|
+
DEV_STORAGE = LocalStorageService(
|
|
148
|
+
ROOT / ".palette" / "dev-storage",
|
|
149
|
+
MANIFEST.get("id", ""),
|
|
150
|
+
app_name=MANIFEST.get("name"),
|
|
151
|
+
organization_id=1,
|
|
152
|
+
organization_slug="palette-dev",
|
|
153
|
+
organization_name="Palette Dev",
|
|
154
|
+
)
|
|
143
155
|
|
|
144
156
|
spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
|
|
145
157
|
module = importlib.util.module_from_spec(spec)
|
|
@@ -178,7 +190,7 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
|
|
|
178
190
|
"secret_specs": MANIFEST.get("secrets") or {},
|
|
179
191
|
"secret_scope": "dev",
|
|
180
192
|
}
|
|
181
|
-
request.state.storage =
|
|
193
|
+
request.state.storage = DEV_STORAGE
|
|
182
194
|
if DEV_REDIS is not None:
|
|
183
195
|
request.state.redis = DEV_REDIS
|
|
184
196
|
if DEV_VECTOR is not None:
|
|
@@ -384,7 +396,10 @@ function escapeHtml(value) {
|
|
|
384
396
|
|
|
385
397
|
async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
|
|
386
398
|
const entry = manifest.frontend?.entry || "./frontend/src/index.tsx"
|
|
387
|
-
const
|
|
399
|
+
const pluginEntry = manifest.frontend?.framework === "palette-app"
|
|
400
|
+
? generatePaletteAppEntry(cwd, entry || "./frontend/app", path.join(devDir, "palette-app-entry.tsx"))
|
|
401
|
+
: entry
|
|
402
|
+
const absEntry = path.resolve(cwd, pluginEntry)
|
|
388
403
|
if (!fs.existsSync(absEntry)) throw new Error(`frontend entry not found: ${entry}`)
|
|
389
404
|
const generatedEntry = path.join(devDir, "simulator-entry.jsx")
|
|
390
405
|
const bundlePath = path.join(devDir, "simulator.js")
|
package/lib/manifest.js
CHANGED
|
@@ -215,8 +215,8 @@ function validateManifest(m) {
|
|
|
215
215
|
requireBoolean(m.frontend, "sandbox", "frontend", errors)
|
|
216
216
|
requireString(m.frontend, "framework", "frontend", errors)
|
|
217
217
|
requireString(m.frontend, "config", "frontend", errors)
|
|
218
|
-
if (m.frontend.framework !== undefined && !["react", "next"].includes(m.frontend.framework)) {
|
|
219
|
-
errors.push('frontend.framework must be "react" or "
|
|
218
|
+
if (m.frontend.framework !== undefined && !["react", "next", "palette-app"].includes(m.frontend.framework)) {
|
|
219
|
+
errors.push('frontend.framework must be "react", "next", or "palette-app"')
|
|
220
220
|
}
|
|
221
221
|
if (m.frontend.config !== undefined && m.frontend.framework !== "next") {
|
|
222
222
|
errors.push('frontend.config is only supported when frontend.framework is "next"')
|
package/package.json
CHANGED
|
@@ -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.16", "backend": "^0.1.8" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -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.16", "backend": "^0.1.8" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -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.16", "backend": "^0.1.8" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -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.16" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"description": "Uses frontend.framework=next so pltt reads frontend/next.config.ts while still publishing a native Palette module.",
|
|
10
10
|
"icon": "Puzzle",
|
|
11
11
|
"gradient": { "bg": "linear-gradient(135deg, #0F766E, #2563EB)", "text": "#fff" },
|
|
12
|
-
"sdk": { "frontend": "^0.1.
|
|
12
|
+
"sdk": { "frontend": "^0.1.16" },
|
|
13
13
|
"platform": { "min_version": "0.1.0" },
|
|
14
14
|
"capabilities": {
|
|
15
15
|
"frontend": true,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Palette App Router Template
|
|
2
|
+
|
|
3
|
+
This template uses Palette-native app-directory routing. It is UI routing only:
|
|
4
|
+
backend APIs, database work, permissions, and secrets stay in `backend/`.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
frontend/app/
|
|
8
|
+
├── layout.tsx
|
|
9
|
+
├── page.tsx
|
|
10
|
+
└── meetings/[meetingId]/page.tsx
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install
|
|
17
|
+
pltt dev
|
|
18
|
+
pltt test
|
|
19
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import { useParams } from "next/navigation"
|
|
3
|
+
|
|
4
|
+
export default function MeetingPage() {
|
|
5
|
+
const params = useParams()
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<section style={{ display: "grid", gap: 16 }}>
|
|
9
|
+
<Link href="/">Back</Link>
|
|
10
|
+
<div>
|
|
11
|
+
<p style={{ margin: 0, color: "#64748b", fontSize: 13 }}>Meeting</p>
|
|
12
|
+
<h1 style={{ margin: "4px 0 0", fontSize: 28 }}>{params.meetingId}</h1>
|
|
13
|
+
</div>
|
|
14
|
+
</section>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Link from "next/link"
|
|
2
|
+
import { usePlatform } from "@palettelab/sdk"
|
|
3
|
+
|
|
4
|
+
export default function HomePage() {
|
|
5
|
+
const platform = usePlatform()
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<section style={{ display: "grid", gap: 16 }}>
|
|
9
|
+
<div>
|
|
10
|
+
<p style={{ margin: 0, color: "#64748b", fontSize: 13 }}>Palette app router</p>
|
|
11
|
+
<h1 style={{ margin: "4px 0 0", fontSize: 28 }}>Welcome, {platform.user.name}</h1>
|
|
12
|
+
</div>
|
|
13
|
+
<p style={{ maxWidth: 560, lineHeight: 1.6 }}>
|
|
14
|
+
Build your app UI with layouts, pages, and dynamic routes while keeping backend APIs in Python.
|
|
15
|
+
</p>
|
|
16
|
+
<Link href="/meetings/demo-meeting">Open dynamic route</Link>
|
|
17
|
+
</section>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": "1",
|
|
3
|
+
"id": "my-palette-app",
|
|
4
|
+
"name": "My Palette App",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"developer": "Your Team",
|
|
7
|
+
"category": "Productivity",
|
|
8
|
+
"tagline": "A routed Palette OS app",
|
|
9
|
+
"description": "Uses Palette-native app-directory routing while publishing as a safe frontend bundle.",
|
|
10
|
+
"icon": "PanelTop",
|
|
11
|
+
"gradient": { "bg": "linear-gradient(135deg, #0F766E, #2563EB)", "text": "#fff" },
|
|
12
|
+
"sdk": { "frontend": "^0.1.16", "backend": "^0.1.8" },
|
|
13
|
+
"platform": { "min_version": "0.1.0" },
|
|
14
|
+
"capabilities": {
|
|
15
|
+
"frontend": true,
|
|
16
|
+
"backend": true,
|
|
17
|
+
"database": false,
|
|
18
|
+
"webhooks": false,
|
|
19
|
+
"scheduled_jobs": false,
|
|
20
|
+
"file_uploads": false,
|
|
21
|
+
"external_network": []
|
|
22
|
+
},
|
|
23
|
+
"frontend": {
|
|
24
|
+
"entry": "./frontend/app",
|
|
25
|
+
"sandbox": true,
|
|
26
|
+
"framework": "palette-app"
|
|
27
|
+
},
|
|
28
|
+
"backend": { "entry": "./backend/api/main.py", "routes_prefix": "/api" },
|
|
29
|
+
"permissions": ["tasks:read"],
|
|
30
|
+
"public_routes": []
|
|
31
|
+
}
|