@palettelab/cli 0.3.37 → 0.3.39

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.
Files changed (30) hide show
  1. package/README.md +31 -1
  2. package/backend-sdk/palette_sdk/manifest.py +1 -1
  3. package/lib/app-router.js +207 -0
  4. package/lib/bundler.js +220 -30
  5. package/lib/commands/init.js +1 -1
  6. package/lib/commands/test.js +7 -3
  7. package/lib/dev-simulator.js +5 -1
  8. package/lib/manifest.js +2 -2
  9. package/package.json +1 -1
  10. package/template-fallback/package.json +1 -1
  11. package/template-fallback/palette-plugin.json +1 -1
  12. package/template-fallback/templates/dashboard/package.json +1 -1
  13. package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
  14. package/template-fallback/templates/database/package.json +1 -1
  15. package/template-fallback/templates/database/palette-plugin.json +1 -1
  16. package/template-fallback/templates/external-service/package.json +1 -1
  17. package/template-fallback/templates/external-service/palette-plugin.json +1 -1
  18. package/template-fallback/templates/frontend-only/package.json +1 -1
  19. package/template-fallback/templates/frontend-only/palette-plugin.json +1 -1
  20. package/template-fallback/templates/next/package.json +1 -1
  21. package/template-fallback/templates/next/palette-plugin.json +1 -1
  22. package/template-fallback/templates/palette-app/README.md +19 -0
  23. package/template-fallback/templates/palette-app/backend/api/main.py +9 -0
  24. package/template-fallback/templates/palette-app/frontend/app/layout.tsx +9 -0
  25. package/template-fallback/templates/palette-app/frontend/app/meetings/[meetingId]/page.tsx +16 -0
  26. package/template-fallback/templates/palette-app/frontend/app/not-found.tsx +10 -0
  27. package/template-fallback/templates/palette-app/frontend/app/page.tsx +19 -0
  28. package/template-fallback/templates/palette-app/package.json +13 -0
  29. package/template-fallback/templates/palette-app/palette-plugin.json +31 -0
  30. package/template-fallback/templates/palette-app/pyproject.toml +9 -0
package/README.md CHANGED
@@ -390,12 +390,42 @@ Creates `data-explorer/` with a valid `palette-plugin.json`, a frontend React en
390
390
  Templates:
391
391
 
392
392
  - `dashboard`
393
+ - `palette-app`
393
394
  - `next`
394
395
  - `agent-tool`
395
396
  - `external-service`
396
397
  - `database`
397
398
  - `frontend-only`
398
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
+
399
429
  ### Next-Compatible Frontend Config
400
430
 
401
431
  Palette native apps publish as a single React module loaded by the OS. They do
@@ -630,7 +660,7 @@ pltt test
630
660
  pltt test --json
631
661
  ```
632
662
 
633
- Checks include manifest validity, SDK/platform compatibility, semver bump detection, forbidden platform imports, frontend bundling and size limits, sandbox bridge smoke, backend dependency installation/import, route permission gates, route permission declarations, migration linting, frontend sandbox policy, and dependency policy for `package.json` / `pyproject.toml`.
663
+ Checks include manifest validity, SDK/platform compatibility, semver bump detection, forbidden platform imports, frontend bundling and size limits, sandbox bridge smoke, backend dependency installation/import, route permission gates, route permission declarations, migration linting, frontend sandbox policy, and dependency policy for `package.json` / `pyproject.toml`. The default frontend bundle limit is 2 MiB; set `PALETTE_MAX_FRONTEND_BUNDLE_BYTES` for reviewed exceptions.
634
664
 
635
665
  ### `pltt package`
636
666
 
@@ -29,7 +29,7 @@ class ToolEntry(BaseModel):
29
29
  class FrontendEntry(BaseModel):
30
30
  entry: str
31
31
  sandbox: bool = True
32
- framework: Literal["react", "next"] = "react"
32
+ framework: Literal["react", "next", "palette-app"] = "react"
33
33
  config: str | None = None
34
34
 
35
35
 
@@ -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 absEntry = path.resolve(pluginDir, entry)
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
- const result = await esbuild.build({
224
- entryPoints: [absEntry],
225
- bundle: true,
226
- format: "esm",
227
- platform: "browser",
228
- target: ["es2022"],
229
- write: false,
230
- jsx: "automatic",
231
- loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
232
- define: buildConfig.define,
233
- external: [
234
- "react",
235
- "react-dom",
236
- "react-dom/client",
237
- "react/jsx-runtime",
238
- "react/jsx-dev-runtime",
239
- "@palettelab/sdk",
240
- ],
241
- minify: true,
242
- sourcemap: "inline",
243
- logLevel: "silent",
244
- absWorkingDir: pluginDir,
245
- plugins: buildConfig.plugins,
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
- if (!result.outputFiles || result.outputFiles.length === 0) {
249
- throw new Error("esbuild produced no output")
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 absEntry = path.resolve(pluginDir, entry)
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
  }
@@ -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
@@ -8,7 +8,7 @@ const { bundleFrontend, bundleBackend } = require("../bundler")
8
8
  const { declaredSecrets, loadLocalEnv } = require("../secrets")
9
9
  const buildCommand = require("./build")
10
10
 
11
- const DEFAULT_FRONTEND_BUNDLE_LIMIT = 512 * 1024
11
+ const DEFAULT_FRONTEND_BUNDLE_LIMIT = 2 * 1024 * 1024
12
12
  const DEFAULT_BACKEND_BUNDLE_LIMIT = 5 * 1024 * 1024
13
13
 
14
14
  function reporter(json, results) {
@@ -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(
@@ -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() {
@@ -395,7 +396,10 @@ function escapeHtml(value) {
395
396
 
396
397
  async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
397
398
  const entry = manifest.frontend?.entry || "./frontend/src/index.tsx"
398
- const absEntry = path.resolve(cwd, entry)
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)
399
403
  if (!fs.existsSync(absEntry)) throw new Error(`frontend entry not found: ${entry}`)
400
404
  const generatedEntry = path.join(devDir, "simulator-entry.jsx")
401
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 "next"')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.15"
7
+ "@palettelab/sdk": "^0.1.16"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -13,7 +13,7 @@
13
13
  "text": "#fff"
14
14
  },
15
15
  "sdk": {
16
- "frontend": "^0.1.15",
16
+ "frontend": "^0.1.16",
17
17
  "backend": "^0.1.0"
18
18
  },
19
19
  "platform": {
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.15",
6
+ "@palettelab/sdk": "^0.1.16",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -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.15", "backend": "^0.1.8" },
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,
@@ -2,5 +2,5 @@
2
2
  "name": "my-db-plugin",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.15", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.16", "react": "^19.0.0" }
6
6
  }
@@ -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.15", "backend": "^0.1.8" },
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,
@@ -2,5 +2,5 @@
2
2
  "name": "my-external-svc",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.15", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.16", "react": "^19.0.0" }
6
6
  }
@@ -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.15", "backend": "^0.1.8" },
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,
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.15",
6
+ "@palettelab/sdk": "^0.1.16",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -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.15" },
12
+ "sdk": { "frontend": "^0.1.16" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.15",
6
+ "@palettelab/sdk": "^0.1.16",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -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.15" },
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,9 @@
1
+ from fastapi import APIRouter
2
+ from palette_sdk import require_permission
3
+
4
+ router = APIRouter()
5
+
6
+
7
+ @router.get("/health", dependencies=[require_permission("tasks:read")])
8
+ async def health():
9
+ return {"ok": True}
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ export default function Layout({ children }: { children: ReactNode }) {
4
+ return (
5
+ <main style={{ minHeight: "100%", padding: 24, fontFamily: "Inter, system-ui, sans-serif" }}>
6
+ {children}
7
+ </main>
8
+ )
9
+ }
@@ -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,10 @@
1
+ import Link from "next/link"
2
+
3
+ export default function NotFoundPage() {
4
+ return (
5
+ <section style={{ display: "grid", gap: 12 }}>
6
+ <h1 style={{ margin: 0, fontSize: 24 }}>Page not found</h1>
7
+ <Link href="/">Return home</Link>
8
+ </section>
9
+ )
10
+ }
@@ -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,13 @@
1
+ {
2
+ "name": "my-palette-app",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@palettelab/sdk": "^0.1.16",
7
+ "react": "^19.0.0"
8
+ },
9
+ "devDependencies": {
10
+ "typescript": "^5.0.0",
11
+ "@types/react": "^19.0.0"
12
+ }
13
+ }
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ [project]
2
+ name = "my-palette-app-backend"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.11"
5
+ dependencies = [
6
+ "fastapi>=0.129.0",
7
+ "sqlalchemy>=2.0.47",
8
+ # `pltt test` and `pltt dev` ship the backend SDK on PYTHONPATH.
9
+ ]