@palettelab/cli 0.3.28 → 0.3.30

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 (37) hide show
  1. package/README.md +33 -3
  2. package/backend-sdk/palette_sdk/__init__.py +11 -1
  3. package/backend-sdk/palette_sdk/db/__init__.py +18 -5
  4. package/backend-sdk/palette_sdk/db/base.py +17 -0
  5. package/backend-sdk/palette_sdk/manifest.py +2 -0
  6. package/docs/python-backend-sdk.md +10 -8
  7. package/lib/bundler.js +200 -3
  8. package/lib/cli.js +1 -1
  9. package/lib/commands/build.js +42 -6
  10. package/lib/commands/dev.js +1 -1
  11. package/lib/commands/doctor.js +1 -1
  12. package/lib/commands/init.js +31 -7
  13. package/lib/commands/package.js +1 -1
  14. package/lib/commands/publish.js +1 -1
  15. package/lib/commands/test.js +8 -4
  16. package/lib/dev-simulator.js +4 -0
  17. package/lib/manifest.js +31 -1
  18. package/package.json +1 -1
  19. package/template-fallback/package.json +1 -1
  20. package/template-fallback/palette-plugin.json +1 -1
  21. package/template-fallback/templates/dashboard/package.json +1 -1
  22. package/template-fallback/templates/dashboard/palette-plugin.json +1 -1
  23. package/template-fallback/templates/database/backend/api/models.py +1 -1
  24. package/template-fallback/templates/database/backend/migrations/versions/001_init.py +5 -5
  25. package/template-fallback/templates/database/package.json +1 -1
  26. package/template-fallback/templates/database/palette-plugin.json +1 -1
  27. package/template-fallback/templates/external-service/package.json +1 -1
  28. package/template-fallback/templates/external-service/palette-plugin.json +1 -1
  29. package/template-fallback/templates/frontend-only/package.json +1 -1
  30. package/template-fallback/templates/frontend-only/palette-plugin.json +1 -1
  31. package/template-fallback/templates/next/README.md +40 -0
  32. package/template-fallback/templates/next/frontend/next.config.ts +8 -0
  33. package/template-fallback/templates/next/frontend/src/index.tsx +30 -0
  34. package/template-fallback/templates/next/frontend/src/translations.ts +14 -0
  35. package/template-fallback/templates/next/frontend/tsconfig.json +15 -0
  36. package/template-fallback/templates/next/package.json +13 -0
  37. package/template-fallback/templates/next/palette-plugin.json +30 -0
package/README.md CHANGED
@@ -224,7 +224,8 @@ Declare database ownership in `palette-plugin.json`:
224
224
  }
225
225
  ```
226
226
 
227
- Define org-scoped models with the backend SDK:
227
+ Define org-scoped models with the backend SDK. Table names must start with the
228
+ app prefix (`my_app__` for `my-app`):
228
229
 
229
230
  ```python
230
231
  from sqlalchemy import String
@@ -232,7 +233,7 @@ from sqlalchemy.orm import Mapped, mapped_column
232
233
  from palette_sdk import OrgScopedTable
233
234
 
234
235
  class Invoice(OrgScopedTable):
235
- __tablename__ = "invoices"
236
+ __tablename__ = "my_app__invoices"
236
237
 
237
238
  id: Mapped[int] = mapped_column(primary_key=True)
238
239
  customer_name: Mapped[str] = mapped_column(String(255))
@@ -245,7 +246,7 @@ from palette_sdk.db import ensure_org_rls
245
246
 
246
247
  def upgrade():
247
248
  op.create_table(...)
248
- ensure_org_rls(op, "invoices")
249
+ ensure_org_rls(op, "my_app__invoices")
249
250
  ```
250
251
 
251
252
  Use `ctx.repo(Model)` for org-safe CRUD:
@@ -341,6 +342,7 @@ Scaffold a new plugin directory from the official template.
341
342
  ```bash
342
343
  pltt init data-explorer
343
344
  pltt init crm-dashboard --template dashboard
345
+ pltt init next-panel --template next
344
346
  cd data-explorer
345
347
  ```
346
348
 
@@ -349,11 +351,39 @@ Creates `data-explorer/` with a valid `palette-plugin.json`, a frontend React en
349
351
  Templates:
350
352
 
351
353
  - `dashboard`
354
+ - `next`
352
355
  - `agent-tool`
353
356
  - `external-service`
354
357
  - `database`
355
358
  - `frontend-only`
356
359
 
360
+ ### Next-Compatible Frontend Config
361
+
362
+ Palette native apps publish as a single React module loaded by the OS. They do
363
+ not run a standalone Next server. When an app needs Next-style frontend config,
364
+ set the manifest to Next-compatible native mode:
365
+
366
+ ```json
367
+ {
368
+ "frontend": {
369
+ "entry": "./frontend/src/index.tsx",
370
+ "sandbox": true,
371
+ "framework": "next",
372
+ "config": "./frontend/next.config.ts"
373
+ }
374
+ }
375
+ ```
376
+
377
+ Put the config file at `frontend/next.config.ts` unless you set a custom
378
+ `frontend.config` path. `pltt dev`, `pltt test`, `pltt package`, and
379
+ `pltt publish` load `next.config.ts/js/mjs/cjs`, apply `env` values plus
380
+ `NEXT_PUBLIC_*` environment variables to the native bundle, and honor path
381
+ aliases from `frontend/tsconfig.json`.
382
+
383
+ Supported today: `env`, `NEXT_PUBLIC_*`, and TypeScript path aliases. Full Next server features
384
+ such as API routes, server components, Next image optimization, middleware, and
385
+ multi-file static export are outside this native module mode.
386
+
357
387
  ### `pltt dev`
358
388
 
359
389
  Run a no-Docker local SDK simulator with your plugin mounted live. Run this from inside your plugin directory.
@@ -12,7 +12,14 @@ from palette_sdk.schemas import (
12
12
  ErrorResponse,
13
13
  PaginatedResponse,
14
14
  )
15
- from palette_sdk.db import OrgScopedTable, PluginBase, ensure_org_rls
15
+ from palette_sdk.db import (
16
+ OrgScopedTable,
17
+ PluginBase,
18
+ ensure_org_rls,
19
+ plugin_safe_id,
20
+ plugin_schema,
21
+ plugin_table_prefix,
22
+ )
16
23
  from palette_sdk.permissions import (
17
24
  KNOWN_PERMISSIONS,
18
25
  is_known_permission,
@@ -39,6 +46,9 @@ __all__ = [
39
46
  "OrgScopedTable",
40
47
  "PluginBase",
41
48
  "ensure_org_rls",
49
+ "plugin_safe_id",
50
+ "plugin_schema",
51
+ "plugin_table_prefix",
42
52
  "KNOWN_PERMISSIONS",
43
53
  "is_known_permission",
44
54
  "require_permission",
@@ -12,7 +12,7 @@ Typical plugin usage:
12
12
  from sqlalchemy.orm import Mapped, mapped_column
13
13
 
14
14
  class Expense(OrgScopedTable):
15
- __tablename__ = "expenses"
15
+ __tablename__ = "my_app__expenses"
16
16
  title: Mapped[str] = mapped_column(String(200))
17
17
  amount_cents: Mapped[int]
18
18
 
@@ -23,16 +23,29 @@ Typical plugin usage:
23
23
 
24
24
  def upgrade():
25
25
  op.create_table(
26
- "expenses",
26
+ "my_app__expenses",
27
27
  sa.Column("id", sa.Integer, primary_key=True),
28
28
  sa.Column("organization_id", sa.BigInteger, nullable=False, index=True),
29
29
  sa.Column("title", sa.String(200), nullable=False),
30
30
  sa.Column("amount_cents", sa.Integer, nullable=False),
31
31
  )
32
- ensure_org_rls(op, "expenses")
32
+ ensure_org_rls(op, "my_app__expenses")
33
33
  """
34
34
 
35
- from palette_sdk.db.base import OrgScopedTable, PluginBase
35
+ from palette_sdk.db.base import (
36
+ OrgScopedTable,
37
+ PluginBase,
38
+ plugin_safe_id,
39
+ plugin_schema,
40
+ plugin_table_prefix,
41
+ )
36
42
  from palette_sdk.db.rls import ensure_org_rls
37
43
 
38
- __all__ = ["OrgScopedTable", "PluginBase", "ensure_org_rls"]
44
+ __all__ = [
45
+ "OrgScopedTable",
46
+ "PluginBase",
47
+ "ensure_org_rls",
48
+ "plugin_safe_id",
49
+ "plugin_schema",
50
+ "plugin_table_prefix",
51
+ ]
@@ -11,6 +11,23 @@ from sqlalchemy import BigInteger, Index
11
11
  from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
12
12
 
13
13
 
14
+ def plugin_safe_id(plugin_id: str) -> str:
15
+ """Return the DB-safe form of a plugin id."""
16
+ import re
17
+
18
+ return re.sub(r"[^a-z0-9_]", "_", plugin_id.lower())
19
+
20
+
21
+ def plugin_schema(plugin_id: str) -> str:
22
+ """Return the Postgres schema name owned by a plugin."""
23
+ return f"app_{plugin_safe_id(plugin_id)}"
24
+
25
+
26
+ def plugin_table_prefix(plugin_id: str) -> str:
27
+ """Return the required table-name prefix for plugin-owned tables."""
28
+ return f"{plugin_safe_id(plugin_id)}__"
29
+
30
+
14
31
  class PluginBase(DeclarativeBase):
15
32
  """Declarative base for plugin models.
16
33
 
@@ -29,6 +29,8 @@ class ToolEntry(BaseModel):
29
29
  class FrontendEntry(BaseModel):
30
30
  entry: str
31
31
  sandbox: bool = True
32
+ framework: Literal["react", "next"] = "react"
33
+ config: str | None = None
32
34
 
33
35
 
34
36
  class BackendEntry(BaseModel):
@@ -183,7 +183,7 @@ from palette_sdk import OrgScopedTable
183
183
 
184
184
 
185
185
  class Invoice(OrgScopedTable):
186
- __tablename__ = "invoices"
186
+ __tablename__ = "finance_tools__invoices"
187
187
 
188
188
  id: Mapped[int] = mapped_column(primary_key=True)
189
189
  customer_name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -191,8 +191,10 @@ class Invoice(OrgScopedTable):
191
191
  status: Mapped[str] = mapped_column(String(32), default="draft")
192
192
  ```
193
193
 
194
- `OrgScopedTable` adds `organization_id` automatically. Do not manually set
195
- `organization_id` in route code; `ctx.repo(Model)` handles it.
194
+ `OrgScopedTable` adds `organization_id` automatically. Table names must start
195
+ with the app prefix (`finance_tools__` for `finance-tools`) so app-owned data is
196
+ easy to identify and validate. Do not manually set `organization_id` in route
197
+ code; `ctx.repo(Model)` handles it.
196
198
 
197
199
  ### Migration Example
198
200
 
@@ -210,22 +212,22 @@ down_revision = None
210
212
 
211
213
  def upgrade():
212
214
  op.create_table(
213
- "invoices",
215
+ "finance_tools__invoices",
214
216
  sa.Column("id", sa.Integer(), primary_key=True),
215
217
  sa.Column("organization_id", sa.BigInteger(), nullable=False),
216
218
  sa.Column("customer_name", sa.String(length=255), nullable=False),
217
219
  sa.Column("amount", sa.Numeric(12, 2), nullable=False),
218
220
  sa.Column("status", sa.String(length=32), nullable=False),
219
221
  )
220
- op.create_index("ix_invoices_org", "invoices", ["organization_id"])
221
- ensure_org_rls(op, "invoices")
222
+ op.create_index("ix_finance_tools__invoices_org", "finance_tools__invoices", ["organization_id"])
223
+ ensure_org_rls(op, "finance_tools__invoices")
222
224
 
223
225
 
224
226
  def downgrade():
225
- op.drop_table("invoices")
227
+ op.drop_table("finance_tools__invoices")
226
228
  ```
227
229
 
228
- `ensure_org_rls(op, "invoices")` enables row-level security so each
230
+ `ensure_org_rls(op, "finance_tools__invoices")` enables row-level security so each
229
231
  organization can only access its own rows.
230
232
 
231
233
  ## 7. Repository CRUD
package/lib/bundler.js CHANGED
@@ -4,6 +4,13 @@ const path = require("path")
4
4
  const fs = require("fs")
5
5
  const os = require("os")
6
6
 
7
+ const NEXT_CONFIG_NAMES = [
8
+ "frontend/next.config.ts",
9
+ "frontend/next.config.mjs",
10
+ "frontend/next.config.js",
11
+ "frontend/next.config.cjs",
12
+ ]
13
+
7
14
  // esbuild is declared as a dependency in package.json; installed via npm install.
8
15
  // We require it lazily so `pltt init` / `pltt dev` / `pltt build` do
9
16
  // not pay the load cost.
@@ -18,6 +25,184 @@ function loadEsbuild() {
18
25
  }
19
26
  }
20
27
 
28
+ function frontendFramework(frontend = {}) {
29
+ return frontend.framework || "react"
30
+ }
31
+
32
+ function resolveNextConfigPath(pluginDir, frontend = {}) {
33
+ if (frontend.config) {
34
+ const explicit = path.resolve(pluginDir, frontend.config)
35
+ if (!fs.existsSync(explicit)) {
36
+ throw new Error(`frontend.config not found: ${frontend.config}`)
37
+ }
38
+ return explicit
39
+ }
40
+
41
+ for (const candidate of NEXT_CONFIG_NAMES) {
42
+ const abs = path.resolve(pluginDir, candidate)
43
+ if (fs.existsSync(abs)) return abs
44
+ }
45
+ return null
46
+ }
47
+
48
+ function requireBundledConfig(absConfig) {
49
+ const esbuild = loadEsbuild()
50
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "palette-next-config-"))
51
+ const outfile = path.join(tmp, "next.config.cjs")
52
+ try {
53
+ esbuild.buildSync({
54
+ entryPoints: [absConfig],
55
+ bundle: true,
56
+ platform: "node",
57
+ target: ["node18"],
58
+ format: "cjs",
59
+ outfile,
60
+ logLevel: "silent",
61
+ external: ["next"],
62
+ })
63
+ delete require.cache[require.resolve(outfile)]
64
+ const mod = require(outfile)
65
+ return mod.default || mod
66
+ } finally {
67
+ fs.rmSync(tmp, { recursive: true, force: true })
68
+ }
69
+ }
70
+
71
+ function loadNextConfig(pluginDir, frontend = {}) {
72
+ if (frontendFramework(frontend) !== "next") return { config: {}, configPath: null }
73
+
74
+ const configPath = resolveNextConfigPath(pluginDir, frontend)
75
+ if (!configPath) {
76
+ return { config: {}, configPath: null }
77
+ }
78
+
79
+ const loaded = requireBundledConfig(configPath)
80
+ const config =
81
+ typeof loaded === "function"
82
+ ? loaded("phase-production-build", { defaultConfig: {} })
83
+ : loaded
84
+ if (config && typeof config.then === "function") {
85
+ throw new Error("async next.config is not supported by Palette native Next mode")
86
+ }
87
+ if (config && (typeof config !== "object" || Array.isArray(config))) {
88
+ throw new Error("next.config must export an object or a function returning an object")
89
+ }
90
+ return { config: config || {}, configPath }
91
+ }
92
+
93
+ function readJson(absPath) {
94
+ try {
95
+ return JSON.parse(fs.readFileSync(absPath, "utf8"))
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ function resolveTsconfig(pluginDir, frontend = {}) {
102
+ const entryDir = frontend.entry ? path.dirname(path.resolve(pluginDir, frontend.entry)) : path.resolve(pluginDir, "frontend")
103
+ const candidates = [
104
+ path.join(entryDir, "tsconfig.json"),
105
+ path.join(pluginDir, "frontend", "tsconfig.json"),
106
+ path.join(pluginDir, "tsconfig.json"),
107
+ ]
108
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null
109
+ }
110
+
111
+ function makeTsconfigPathsPlugin(pluginDir, frontend = {}) {
112
+ const tsconfigPath = resolveTsconfig(pluginDir, frontend)
113
+ if (!tsconfigPath) return null
114
+
115
+ const tsconfig = readJson(tsconfigPath)
116
+ const compilerOptions = tsconfig?.compilerOptions || {}
117
+ const paths = compilerOptions.paths || {}
118
+ const baseUrl = path.resolve(path.dirname(tsconfigPath), compilerOptions.baseUrl || ".")
119
+ const mappings = []
120
+
121
+ for (const [pattern, targets] of Object.entries(paths)) {
122
+ if (!Array.isArray(targets) || targets.length === 0) continue
123
+ const starIndex = pattern.indexOf("*")
124
+ mappings.push({
125
+ pattern,
126
+ prefix: starIndex >= 0 ? pattern.slice(0, starIndex) : pattern,
127
+ suffix: starIndex >= 0 ? pattern.slice(starIndex + 1) : "",
128
+ hasStar: starIndex >= 0,
129
+ targets,
130
+ })
131
+ }
132
+
133
+ if (mappings.length === 0) return null
134
+
135
+ const extensions = ["", ".tsx", ".ts", ".jsx", ".js", ".mjs", ".json"]
136
+ const resolveTarget = (target) => {
137
+ for (const ext of extensions) {
138
+ const candidate = path.resolve(baseUrl, `${target}${ext}`)
139
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
140
+ }
141
+ const indexCandidates = ["index.tsx", "index.ts", "index.jsx", "index.js", "index.mjs"]
142
+ for (const indexFile of indexCandidates) {
143
+ const candidate = path.resolve(baseUrl, target, indexFile)
144
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
145
+ }
146
+ return null
147
+ }
148
+
149
+ return {
150
+ name: "palette-tsconfig-paths",
151
+ setup(build) {
152
+ build.onResolve({ filter: /^[^./].*/ }, (args) => {
153
+ for (const mapping of mappings) {
154
+ let starValue = ""
155
+ if (mapping.hasStar) {
156
+ if (!args.path.startsWith(mapping.prefix) || !args.path.endsWith(mapping.suffix)) continue
157
+ starValue = args.path.slice(mapping.prefix.length, args.path.length - mapping.suffix.length)
158
+ } else if (args.path !== mapping.pattern) {
159
+ continue
160
+ }
161
+
162
+ for (const target of mapping.targets) {
163
+ const resolved = resolveTarget(String(target).replace("*", starValue))
164
+ if (resolved) return { path: resolved }
165
+ }
166
+ }
167
+ return null
168
+ })
169
+ },
170
+ }
171
+ }
172
+
173
+ function frontendBuildConfig(pluginDir, frontend = {}) {
174
+ const framework = frontendFramework(frontend)
175
+ const define = {
176
+ "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "production"),
177
+ }
178
+ const plugins = []
179
+ const tsconfigPaths = framework === "next" ? makeTsconfigPathsPlugin(pluginDir, frontend) : null
180
+ if (tsconfigPaths) plugins.push(tsconfigPaths)
181
+
182
+ let nextConfigPath = null
183
+ if (framework === "next") {
184
+ const { config, configPath } = loadNextConfig(pluginDir, frontend)
185
+ nextConfigPath = configPath
186
+ for (const [key, value] of Object.entries(process.env)) {
187
+ if (key.startsWith("NEXT_PUBLIC_")) {
188
+ define[`process.env.${key}`] = JSON.stringify(value)
189
+ }
190
+ }
191
+ for (const [key, value] of Object.entries(config.env || {})) {
192
+ if (/^[A-Z0-9_]+$/i.test(key)) {
193
+ define[`process.env.${key}`] = JSON.stringify(value)
194
+ }
195
+ }
196
+ define["process.env.NEXT_RUNTIME"] = JSON.stringify("browser")
197
+ }
198
+
199
+ return { define, plugins, framework, nextConfigPath }
200
+ }
201
+
202
+ function mergePlugins(...pluginGroups) {
203
+ return pluginGroups.flat().filter(Boolean)
204
+ }
205
+
21
206
  /**
22
207
  * Bundle the plugin's frontend entry into a single ESM file.
23
208
  *
@@ -26,13 +211,14 @@ function loadEsbuild() {
26
211
  *
27
212
  * Returns the bundle as a Buffer.
28
213
  */
29
- async function bundleFrontend(pluginDir, entry) {
214
+ async function bundleFrontend(pluginDir, entry, frontend = {}) {
30
215
  pluginDir = path.resolve(pluginDir)
31
216
  const esbuild = loadEsbuild()
32
217
  const absEntry = path.resolve(pluginDir, entry)
33
218
  if (!fs.existsSync(absEntry)) {
34
219
  throw new Error(`frontend entry not found: ${entry}`)
35
220
  }
221
+ const buildConfig = frontendBuildConfig(pluginDir, { ...frontend, entry })
36
222
 
37
223
  const result = await esbuild.build({
38
224
  entryPoints: [absEntry],
@@ -43,6 +229,7 @@ async function bundleFrontend(pluginDir, entry) {
43
229
  write: false,
44
230
  jsx: "automatic",
45
231
  loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
232
+ define: buildConfig.define,
46
233
  external: [
47
234
  "react",
48
235
  "react-dom",
@@ -55,6 +242,7 @@ async function bundleFrontend(pluginDir, entry) {
55
242
  sourcemap: "inline",
56
243
  logLevel: "silent",
57
244
  absWorkingDir: pluginDir,
245
+ plugins: buildConfig.plugins,
58
246
  })
59
247
 
60
248
  if (!result.outputFiles || result.outputFiles.length === 0) {
@@ -63,7 +251,7 @@ async function bundleFrontend(pluginDir, entry) {
63
251
  return Buffer.from(result.outputFiles[0].contents)
64
252
  }
65
253
 
66
- async function watchFrontend(pluginDir, entry, outfile) {
254
+ async function watchFrontend(pluginDir, entry, outfile, frontend = {}) {
67
255
  pluginDir = path.resolve(pluginDir)
68
256
  outfile = path.resolve(outfile)
69
257
  const esbuild = loadEsbuild()
@@ -71,6 +259,7 @@ async function watchFrontend(pluginDir, entry, outfile) {
71
259
  if (!fs.existsSync(absEntry)) {
72
260
  throw new Error(`frontend entry not found: ${entry}`)
73
261
  }
262
+ const buildConfig = frontendBuildConfig(pluginDir, { ...frontend, entry })
74
263
 
75
264
  fs.mkdirSync(path.dirname(outfile), { recursive: true })
76
265
 
@@ -83,6 +272,7 @@ async function watchFrontend(pluginDir, entry, outfile) {
83
272
  outfile,
84
273
  jsx: "automatic",
85
274
  loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
275
+ define: buildConfig.define,
86
276
  external: [
87
277
  "react",
88
278
  "react-dom",
@@ -96,6 +286,7 @@ async function watchFrontend(pluginDir, entry, outfile) {
96
286
  logLevel: "silent",
97
287
  absWorkingDir: pluginDir,
98
288
  plugins: [
289
+ ...buildConfig.plugins,
99
290
  {
100
291
  name: "palette-dev-watch-logger",
101
292
  setup(build) {
@@ -176,4 +367,10 @@ async function bundleBackend(pluginDir) {
176
367
  }
177
368
  }
178
369
 
179
- module.exports = { bundleFrontend, bundleBackend, watchFrontend }
370
+ module.exports = {
371
+ bundleFrontend,
372
+ bundleBackend,
373
+ watchFrontend,
374
+ frontendBuildConfig,
375
+ mergePlugins,
376
+ }
package/lib/cli.js CHANGED
@@ -64,7 +64,7 @@ function printHelp() {
64
64
  console.log(" --token <token> Publish token for pltt login")
65
65
  console.log(" --no-default Do not make this environment the default")
66
66
  console.log("\nInit flags:")
67
- console.log(" --template <name> One of: dashboard, agent-tool, external-service, database, frontend-only")
67
+ console.log(" --template <name> One of: dashboard, agent-tool, external-service, database, frontend-only, next")
68
68
  console.log("\nLogs flags:")
69
69
  console.log(" --tail <n> Tail last n events (default 50)")
70
70
  console.log(" -f, --follow Stream events (poll every 3s)")
@@ -19,9 +19,35 @@ const BANNED_PATTERNS = [
19
19
  { re: /\bDROP\s+SCHEMA\b/i, reason: "DROP SCHEMA is not allowed in plugin migrations" },
20
20
  ]
21
21
 
22
- function lintMigrationFile(absPath) {
22
+ function pluginSafeId(pluginId) {
23
+ return String(pluginId || "").toLowerCase().replace(/[^a-z0-9_]/g, "_")
24
+ }
25
+
26
+ function pluginSchema(pluginId) {
27
+ return `app_${pluginSafeId(pluginId)}`
28
+ }
29
+
30
+ function pluginTablePrefix(pluginId) {
31
+ return `${pluginSafeId(pluginId)}__`
32
+ }
33
+
34
+ function crossPluginSchemaRefs(src, allowedSchema) {
35
+ if (!allowedSchema) return []
36
+ const refs = new Set()
37
+ const re = /(^|[^a-z0-9_])"?((?:app)_[a-z0-9_]+)"?\s*\./gi
38
+ let match
39
+ while ((match = re.exec(src)) !== null) {
40
+ const schema = match[2].toLowerCase()
41
+ if (schema !== allowedSchema) refs.add(schema)
42
+ }
43
+ return [...refs].sort()
44
+ }
45
+
46
+ function lintMigrationFile(absPath, pluginId) {
23
47
  const issues = []
24
48
  const src = fs.readFileSync(absPath, "utf8")
49
+ const requiredPrefix = pluginId ? pluginTablePrefix(pluginId) : null
50
+ const allowedSchema = pluginId ? pluginSchema(pluginId) : null
25
51
 
26
52
  for (const { re, reason } of BANNED_PATTERNS) {
27
53
  if (re.test(src)) {
@@ -29,12 +55,16 @@ function lintMigrationFile(absPath) {
29
55
  }
30
56
  }
31
57
 
58
+ for (const schema of crossPluginSchemaRefs(src, allowedSchema)) {
59
+ issues.push(`${path.basename(absPath)}: plugin migrations must not reference another app schema (${schema})`)
60
+ }
61
+
32
62
  // Every op.create_table("foo", ...) in the file must have a matching
33
63
  // ensure_org_rls(op, "foo") somewhere in the same file. Caveat: this is a
34
64
  // cheap syntactic check, not a full AST walk. If your table name is dynamic
35
65
  // or your migration is unusual, you can silence the check by adding the
36
66
  // magic comment `# palette:rls-ok` on the same logical migration.
37
- if (/#\s*palette:rls-ok\b/.test(src)) return issues
67
+ const skipRlsCheck = /#\s*palette:rls-ok\b/.test(src)
38
68
 
39
69
  const tableNames = new Set()
40
70
  const createTableRe = /op\.create_table\(\s*['"]([a-zA-Z_][a-zA-Z0-9_]*)['"]/g
@@ -44,8 +74,13 @@ function lintMigrationFile(absPath) {
44
74
  }
45
75
 
46
76
  for (const name of tableNames) {
77
+ if (requiredPrefix && !name.startsWith(requiredPrefix)) {
78
+ issues.push(
79
+ `${path.basename(absPath)}: create_table("${name}") must use the app table prefix "${requiredPrefix}"`,
80
+ )
81
+ }
47
82
  const rlsRe = new RegExp(`ensure_org_rls\\(\\s*op\\s*,\\s*['"]${name}['"]`)
48
- if (!rlsRe.test(src)) {
83
+ if (!skipRlsCheck && !rlsRe.test(src)) {
49
84
  issues.push(
50
85
  `${path.basename(absPath)}: create_table("${name}") is missing ensure_org_rls(op, "${name}"). ` +
51
86
  `Inherit from OrgScopedTable and call ensure_org_rls, or mark the migration with # palette:rls-ok if the table is intentionally global.`,
@@ -56,7 +91,7 @@ function lintMigrationFile(absPath) {
56
91
  return issues
57
92
  }
58
93
 
59
- function lintMigrationsDir(migrationsDir) {
94
+ function lintMigrationsDir(migrationsDir, pluginId) {
60
95
  const errors = []
61
96
  const versionsDir = path.join(migrationsDir, "versions")
62
97
  if (!fs.existsSync(versionsDir)) {
@@ -66,7 +101,7 @@ function lintMigrationsDir(migrationsDir) {
66
101
  for (const entry of fs.readdirSync(versionsDir)) {
67
102
  if (!entry.endsWith(".py")) continue
68
103
  const abs = path.join(versionsDir, entry)
69
- errors.push(...lintMigrationFile(abs))
104
+ errors.push(...lintMigrationFile(abs, pluginId))
70
105
  }
71
106
  return errors
72
107
  }
@@ -92,7 +127,7 @@ async function run(args, { cwd }) {
92
127
  } else if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
93
128
  errors.push(`database.migrations directory is missing env.py: ${migrationsRel}`)
94
129
  } else {
95
- errors.push(...lintMigrationsDir(migrationsAbs))
130
+ errors.push(...lintMigrationsDir(migrationsAbs, manifest.id))
96
131
  }
97
132
  }
98
133
 
@@ -107,3 +142,4 @@ async function run(args, { cwd }) {
107
142
  module.exports = run
108
143
  module.exports.lintMigrationsDir = lintMigrationsDir
109
144
  module.exports.lintMigrationFile = lintMigrationFile
145
+ module.exports.pluginTablePrefix = pluginTablePrefix
@@ -132,7 +132,7 @@ async function run(args, { cwd }) {
132
132
  if (manifest.frontend?.entry) {
133
133
  console.log(`[pltt] bundling frontend ${frontendEntry} → .palette/dist/frontend.mjs`)
134
134
  try {
135
- frontendWatcher = await watchFrontend(cwd, frontendEntry, frontendBundle)
135
+ frontendWatcher = await watchFrontend(cwd, frontendEntry, frontendBundle, manifest.frontend)
136
136
  } catch (err) {
137
137
  console.error(
138
138
  `[pltt] could not start frontend bundler: ${
@@ -122,7 +122,7 @@ async function run(args, { cwd }) {
122
122
 
123
123
  if (manifest.frontend?.entry) {
124
124
  try {
125
- const bundle = await bundleFrontend(cwd, manifest.frontend.entry)
125
+ const bundle = await bundleFrontend(cwd, manifest.frontend.entry, manifest.frontend)
126
126
  ok(`frontend bundles successfully (${bundle.length} bytes)`)
127
127
  } catch (err) {
128
128
  failures += fail(
@@ -9,7 +9,7 @@ const DEFAULT_TEMPLATE_REPO = "palette-lab/plugin-template"
9
9
  const TEMPLATE_REPO = process.env.PALETTE_TEMPLATE_REPO || DEFAULT_TEMPLATE_REPO
10
10
  const TEMPLATE_REF = process.env.PALETTE_TEMPLATE_REF || "main"
11
11
 
12
- const KNOWN_TEMPLATES = ["frontend-only", "dashboard", "agent-tool", "external-service", "database"]
12
+ const KNOWN_TEMPLATES = ["frontend-only", "next", "dashboard", "agent-tool", "external-service", "database"]
13
13
 
14
14
  function toSlug(name) {
15
15
  return name
@@ -19,6 +19,10 @@ function toSlug(name) {
19
19
  .replace(/^-|-$/g, "")
20
20
  }
21
21
 
22
+ function toDbSafeId(pluginId) {
23
+ return pluginId.toLowerCase().replace(/[^a-z0-9_]/g, "_")
24
+ }
25
+
22
26
  function fetchTemplate(destDir) {
23
27
  const git = spawnSync(
24
28
  "git",
@@ -69,11 +73,16 @@ function rewriteScaffold(destDir, slug, displayName) {
69
73
  if (!fs.existsSync(manifestPath)) return
70
74
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"))
71
75
  const originalId = manifest.id
76
+ const originalDbSafeId = originalId ? toDbSafeId(originalId) : null
77
+ const nextDbSafeId = toDbSafeId(slug)
72
78
  if (originalId && originalId !== slug) {
73
79
  walkFiles(destDir, (filePath) => {
74
80
  try {
75
81
  const before = fs.readFileSync(filePath, "utf8")
76
- const after = before.split(originalId).join(slug)
82
+ let after = before.split(originalId).join(slug)
83
+ if (originalDbSafeId && originalDbSafeId !== nextDbSafeId) {
84
+ after = after.split(originalDbSafeId).join(nextDbSafeId)
85
+ }
77
86
  if (after !== before) fs.writeFileSync(filePath, after)
78
87
  } catch (_err) {
79
88
  // Ignore non-text files. Templates are expected to be text, but this
@@ -85,7 +94,7 @@ function rewriteScaffold(destDir, slug, displayName) {
85
94
  rewrittenManifest.id = slug
86
95
  rewrittenManifest.name = displayName
87
96
  if (rewrittenManifest.database?.schema) {
88
- rewrittenManifest.database.schema = `app_${slug.replace(/-/g, "_")}`
97
+ rewrittenManifest.database.schema = `app_${nextDbSafeId}`
89
98
  }
90
99
  fs.writeFileSync(manifestPath, JSON.stringify(rewrittenManifest, null, 2) + "\n")
91
100
  }
@@ -96,6 +105,14 @@ function getOpt(args, name) {
96
105
  return null
97
106
  }
98
107
 
108
+ function hasPathSegment(value) {
109
+ return value.includes("/") || value.includes("\\")
110
+ }
111
+
112
+ function targetName(input) {
113
+ return path.basename(input.replace(/[\\/]+$/, ""))
114
+ }
115
+
99
116
  async function run(args, { cwd }) {
100
117
  const positional = args.filter((a) => !a.startsWith("-"))
101
118
  const name = positional[0]
@@ -110,11 +127,18 @@ async function run(args, { cwd }) {
110
127
  console.error(`[pltt] templates: ${KNOWN_TEMPLATES.join(", ")}`)
111
128
  process.exit(1)
112
129
  }
113
- const slug = toSlug(name)
114
- const displayName = name
130
+ const targetBase = targetName(name)
131
+ const slug = toSlug(targetBase)
132
+ if (!slug) {
133
+ console.error(`[pltt] invalid plugin name: ${name}`)
134
+ process.exit(1)
135
+ }
136
+ const displayName = targetBase
115
137
  .replace(/[-_]+/g, " ")
116
138
  .replace(/\b\w/g, (c) => c.toUpperCase())
117
- const destDir = path.join(cwd, slug)
139
+ const destDir = path.isAbsolute(name) || hasPathSegment(name)
140
+ ? path.resolve(cwd, name)
141
+ : path.join(cwd, slug)
118
142
  if (fs.existsSync(destDir)) {
119
143
  console.error(`[pltt] directory already exists: ${destDir}`)
120
144
  process.exit(1)
@@ -144,7 +168,7 @@ async function run(args, { cwd }) {
144
168
 
145
169
  console.log(`[pltt] created ${destDir}`)
146
170
  console.log("[pltt] next steps:")
147
- console.log(` cd ${slug}`)
171
+ console.log(` cd ${path.relative(cwd, destDir) || "."}`)
148
172
  console.log(" npm install")
149
173
  console.log(` npx @palettelab/cli dev`)
150
174
  console.log(` # or, after global install: pltt dev`)
@@ -24,7 +24,7 @@ async function run(argv, { cwd }) {
24
24
  fs.mkdirSync(distDir, { recursive: true })
25
25
 
26
26
  const frontend = manifest.frontend
27
- ? await bundleFrontend(cwd, manifest.frontend.entry || "./frontend/src/index.tsx")
27
+ ? await bundleFrontend(cwd, manifest.frontend.entry || "./frontend/src/index.tsx", manifest.frontend)
28
28
  : null
29
29
  const backend = manifest.backend ? await bundleBackend(cwd) : null
30
30
 
@@ -125,7 +125,7 @@ async function run(argv, { cwd }) {
125
125
  let frontend = null
126
126
  if (manifest.frontend?.entry) {
127
127
  log("[pltt] bundling frontend")
128
- frontend = await bundleFrontend(cwd, manifest.frontend.entry)
128
+ frontend = await bundleFrontend(cwd, manifest.frontend.entry, manifest.frontend)
129
129
  log(`[pltt] ${frontend.length} bytes`)
130
130
  } else {
131
131
  log("[pltt] no frontend declared")
@@ -252,7 +252,7 @@ function lintMigrations(cwd, manifest, out) {
252
252
  if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
253
253
  return out.fail(`database.migrations directory is missing env.py: ${migrationsRel}`)
254
254
  }
255
- const errors = buildCommand.lintMigrationsDir(migrationsAbs)
255
+ const errors = buildCommand.lintMigrationsDir(migrationsAbs, manifest.id)
256
256
  for (const err of errors) out.fail(err)
257
257
  if (errors.length === 0) out.ok("migration lint passed")
258
258
  return errors.length
@@ -498,9 +498,13 @@ function scanForbiddenImports(cwd, manifest, out) {
498
498
  const roots = []
499
499
  if (manifest.frontend?.entry) roots.push(path.resolve(cwd, "frontend"))
500
500
  if (manifest.backend?.entry) roots.push(path.resolve(cwd, "backend"))
501
+ const allowLocalNextAlias = manifest.frontend?.framework === "next"
502
+ const frontendImportPrefix = allowLocalNextAlias
503
+ ? "(?:app/|backend/|frontend/)"
504
+ : "(?:@/|app/|backend/|frontend/)"
501
505
  const forbidden = [
502
- { re: /from\s+["'](?:@\/|app\/|backend\/|frontend\/)/, reason: "frontend imports platform source" },
503
- { re: /import\s+["'](?:@\/|app\/|backend\/|frontend\/)/, reason: "frontend imports platform source" },
506
+ { re: new RegExp(`from\\s+["']${frontendImportPrefix}`), reason: "frontend imports platform source" },
507
+ { re: new RegExp(`import\\s+["']${frontendImportPrefix}`), reason: "frontend imports platform source" },
504
508
  { re: /^\s*(?:from|import)\s+app(?:\.|\s)/m, reason: "backend imports platform app source" },
505
509
  ]
506
510
  const issues = []
@@ -655,7 +659,7 @@ async function run(args, { cwd }) {
655
659
 
656
660
  if (manifest.frontend?.entry) {
657
661
  try {
658
- const frontend = await bundleFrontend(cwd, manifest.frontend.entry)
662
+ const frontend = await bundleFrontend(cwd, manifest.frontend.entry, manifest.frontend)
659
663
  out.ok(`frontend bundles successfully (${frontend.length} bytes)`, { bytes: frontend.length })
660
664
  failures += checkBundleSize("frontend", frontend.length, out)
661
665
  failures += sandboxBridgeSmoke(cwd, manifest, out)
@@ -6,6 +6,7 @@ const path = require("path")
6
6
  const { spawn, spawnSync } = require("child_process")
7
7
 
8
8
  const { loadManifest } = require("./manifest")
9
+ const { frontendBuildConfig } = require("./bundler")
9
10
 
10
11
  function loadEsbuild() {
11
12
  try {
@@ -360,6 +361,7 @@ async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
360
361
  const generatedEntry = path.join(devDir, "simulator-entry.jsx")
361
362
  const bundlePath = path.join(devDir, "simulator.js")
362
363
  fs.writeFileSync(generatedEntry, simulatorEntrySource(absEntry, manifest, backendPort))
364
+ const buildConfig = frontendBuildConfig(cwd, { ...(manifest.frontend || {}), entry })
363
365
 
364
366
  const esbuild = loadEsbuild()
365
367
  const ctx = await esbuild.context({
@@ -371,10 +373,12 @@ async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
371
373
  outfile: bundlePath,
372
374
  jsx: "automatic",
373
375
  loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
376
+ define: buildConfig.define,
374
377
  absWorkingDir: cwd,
375
378
  sourcemap: "inline",
376
379
  logLevel: "silent",
377
380
  plugins: [
381
+ ...buildConfig.plugins,
378
382
  {
379
383
  name: "palette-simulator-watch",
380
384
  setup(build) {
package/lib/manifest.js CHANGED
@@ -69,6 +69,18 @@ function isObject(v) {
69
69
  return v !== null && typeof v === "object" && !Array.isArray(v)
70
70
  }
71
71
 
72
+ function pluginSafeId(pluginId) {
73
+ return String(pluginId || "").toLowerCase().replace(/[^a-z0-9_]/g, "_")
74
+ }
75
+
76
+ function expectedPluginSchema(pluginId) {
77
+ return `app_${pluginSafeId(pluginId)}`
78
+ }
79
+
80
+ function expectedTablePrefix(pluginId) {
81
+ return `${pluginSafeId(pluginId)}__`
82
+ }
83
+
72
84
  function unknownKeys(obj, allowed, label, errors) {
73
85
  if (!isObject(obj)) return
74
86
  for (const key of Object.keys(obj)) {
@@ -127,11 +139,19 @@ function validateManifest(m) {
127
139
  if (m.frontend !== undefined) {
128
140
  if (!isObject(m.frontend)) errors.push("frontend must be an object")
129
141
  else {
130
- unknownKeys(m.frontend, new Set(["entry", "sandbox"]), "frontend", errors)
142
+ unknownKeys(m.frontend, new Set(["entry", "sandbox", "framework", "config"]), "frontend", errors)
131
143
  if (!m.frontend.entry || typeof m.frontend.entry !== "string") {
132
144
  errors.push("frontend.entry is required when frontend is set")
133
145
  }
134
146
  requireBoolean(m.frontend, "sandbox", "frontend", errors)
147
+ requireString(m.frontend, "framework", "frontend", errors)
148
+ requireString(m.frontend, "config", "frontend", errors)
149
+ if (m.frontend.framework !== undefined && !["react", "next"].includes(m.frontend.framework)) {
150
+ errors.push('frontend.framework must be "react" or "next"')
151
+ }
152
+ if (m.frontend.config !== undefined && m.frontend.framework !== "next") {
153
+ errors.push('frontend.config is only supported when frontend.framework is "next"')
154
+ }
135
155
  }
136
156
  }
137
157
  if (m.backend !== undefined) {
@@ -241,11 +261,21 @@ function validateManifest(m) {
241
261
  }
242
262
  if (m.database.schema !== undefined && !/^app_[a-z0-9_]+$/.test(m.database.schema)) {
243
263
  errors.push("database.schema must match /^app_[a-z0-9_]+$/")
264
+ } else if (m.database.schema !== undefined && m.id && m.database.schema !== expectedPluginSchema(m.id)) {
265
+ errors.push(`database.schema must be ${expectedPluginSchema(m.id)} for plugin ${m.id}`)
244
266
  }
245
267
  }
246
268
  }
247
269
 
248
270
  validateArray(m.shared_tables, "shared_tables", errors)
271
+ if (Array.isArray(m.shared_tables) && m.id) {
272
+ const prefix = expectedTablePrefix(m.id)
273
+ for (const table of m.shared_tables) {
274
+ if (typeof table === "string" && !table.startsWith(prefix)) {
275
+ errors.push(`shared_tables entries must use the app table prefix "${prefix}": ${table}`)
276
+ }
277
+ }
278
+ }
249
279
  validateArray(m.permissions, "permissions", errors)
250
280
  if (Array.isArray(m.permissions)) {
251
281
  const seen = new Set()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.28",
3
+ "version": "0.3.30",
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.11"
7
+ "@palettelab/sdk": "^0.1.12"
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.11",
16
+ "frontend": "^0.1.12",
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.11",
6
+ "@palettelab/sdk": "^0.1.12",
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.11", "backend": "^0.1.0" },
12
+ "sdk": { "frontend": "^0.1.12", "backend": "^0.1.0" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -5,7 +5,7 @@ from palette_sdk.db import OrgScopedTable
5
5
 
6
6
 
7
7
  class Note(OrgScopedTable):
8
- __tablename__ = "notes"
8
+ __tablename__ = "my_db_plugin__notes"
9
9
 
10
10
  id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
11
11
  body: Mapped[str] = mapped_column(Text, nullable=False)
@@ -11,15 +11,15 @@ down_revision = None
11
11
 
12
12
  def upgrade() -> None:
13
13
  op.create_table(
14
- "notes",
14
+ "my_db_plugin__notes",
15
15
  sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
16
16
  sa.Column("organization_id", sa.BigInteger(), nullable=False),
17
17
  sa.Column("body", sa.Text(), nullable=False),
18
18
  )
19
- op.create_index("ix_notes_org", "notes", ["organization_id"])
20
- ensure_org_rls(op, "notes")
19
+ op.create_index("ix_my_db_plugin__notes_org", "my_db_plugin__notes", ["organization_id"])
20
+ ensure_org_rls(op, "my_db_plugin__notes")
21
21
 
22
22
 
23
23
  def downgrade() -> None:
24
- op.drop_index("ix_notes_org", table_name="notes")
25
- op.drop_table("notes")
24
+ op.drop_index("ix_my_db_plugin__notes_org", table_name="my_db_plugin__notes")
25
+ op.drop_table("my_db_plugin__notes")
@@ -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.11", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.12", "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.11", "backend": "^0.1.0" },
12
+ "sdk": { "frontend": "^0.1.12", "backend": "^0.1.0" },
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.11", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.12", "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.11", "backend": "^0.1.0" },
12
+ "sdk": { "frontend": "^0.1.12", "backend": "^0.1.0" },
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.11",
6
+ "@palettelab/sdk": "^0.1.12",
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.11" },
12
+ "sdk": { "frontend": "^0.1.12" },
13
13
  "platform": { "min_version": "0.1.0" },
14
14
  "capabilities": {
15
15
  "frontend": true,
@@ -0,0 +1,40 @@
1
+ # Next-Compatible Palette Plugin
2
+
3
+ This template keeps config at `frontend/next.config.ts`.
4
+
5
+ Palette native apps still publish as a single React module, so `pltt` does not
6
+ run a full Next server. In `frontend.framework: "next"` mode the CLI reads the
7
+ Next config for supported native-bundle settings.
8
+
9
+ ## Files
10
+
11
+ ```text
12
+ frontend/
13
+ ├── next.config.ts
14
+ ├── tsconfig.json
15
+ └── src/
16
+ ├── index.tsx
17
+ └── translations.ts
18
+ ```
19
+
20
+ The manifest enables this mode:
21
+
22
+ ```json
23
+ {
24
+ "frontend": {
25
+ "entry": "./frontend/src/index.tsx",
26
+ "sandbox": true,
27
+ "framework": "next",
28
+ "config": "./frontend/next.config.ts"
29
+ }
30
+ }
31
+ ```
32
+
33
+ `pltt dev`, `pltt test`, `pltt package`, and `pltt publish` load
34
+ `next.config.ts/js/mjs/cjs`, apply `env` values plus `NEXT_PUBLIC_*`
35
+ environment variables to the native bundle, and honor aliases from
36
+ `frontend/tsconfig.json`.
37
+
38
+ Supported today: `env`, `NEXT_PUBLIC_*`, and TypeScript path aliases. Full Next
39
+ server features such as API routes, server components, middleware, image
40
+ optimization, and static export hosting are outside this native module mode.
@@ -0,0 +1,8 @@
1
+ const nextConfig = {
2
+ env: {
3
+ NEXT_PUBLIC_PLUGIN_FRAMEWORK: "next",
4
+ },
5
+ transpilePackages: ["@palettelab/sdk"],
6
+ }
7
+
8
+ export default nextConfig
@@ -0,0 +1,30 @@
1
+ "use client"
2
+
3
+ import { usePluginTranslations } from "@palettelab/sdk"
4
+ import type { PluginComponentProps } from "@palettelab/sdk"
5
+ import { translations } from "@/translations"
6
+
7
+ export default function NextCompatiblePlugin(_props: PluginComponentProps) {
8
+ const { t, language, setLanguage } = usePluginTranslations(translations)
9
+ const framework = process.env.NEXT_PUBLIC_PLUGIN_FRAMEWORK || "next"
10
+
11
+ return (
12
+ <main className="p-6 space-y-4">
13
+ <div className="flex items-start justify-between gap-3">
14
+ <div>
15
+ <h1 className="text-2xl font-bold">{t("title")}</h1>
16
+ <p className="mt-2 text-muted-foreground">{t("body")}</p>
17
+ </div>
18
+ <button
19
+ className="px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
20
+ onClick={() => setLanguage(language === "ko" ? "en" : "ko")}
21
+ >
22
+ {language === "ko" ? "EN" : "KO"}
23
+ </button>
24
+ </div>
25
+ <p className="text-sm text-muted-foreground">
26
+ {t("framework", { framework })}
27
+ </p>
28
+ </main>
29
+ )
30
+ }
@@ -0,0 +1,14 @@
1
+ import type { TranslationResources } from "@palettelab/sdk"
2
+
3
+ export const translations = {
4
+ en: {
5
+ title: "Next-Compatible App",
6
+ body: "This app reads frontend/next.config.ts and follows the Palette OS language.",
7
+ framework: "Framework: {{framework}}",
8
+ },
9
+ ko: {
10
+ title: "Next 호환 앱",
11
+ body: "이 앱은 frontend/next.config.ts를 읽고 Palette OS 언어를 따릅니다.",
12
+ framework: "프레임워크: {{framework}}",
13
+ },
14
+ } satisfies TranslationResources
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "@/*": ["src/*"]
6
+ },
7
+ "jsx": "react-jsx",
8
+ "module": "esnext",
9
+ "moduleResolution": "bundler",
10
+ "target": "es2022",
11
+ "strict": true,
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["src/**/*.ts", "src/**/*.tsx", "next.config.ts"]
15
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "my-next-plugin",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@palettelab/sdk": "^0.1.12",
7
+ "react": "^19.0.0"
8
+ },
9
+ "devDependencies": {
10
+ "typescript": "^5.0.0",
11
+ "@types/react": "^19.0.0"
12
+ }
13
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "manifest_version": "1",
3
+ "id": "my-next-plugin",
4
+ "name": "My Next Plugin",
5
+ "version": "1.0.0",
6
+ "developer": "Your Team",
7
+ "category": "Productivity",
8
+ "tagline": "A native Palette app with Next-compatible frontend config",
9
+ "description": "Uses frontend.framework=next so pltt reads frontend/next.config.ts while still publishing a native Palette module.",
10
+ "icon": "Puzzle",
11
+ "gradient": { "bg": "linear-gradient(135deg, #0F766E, #2563EB)", "text": "#fff" },
12
+ "sdk": { "frontend": "^0.1.12" },
13
+ "platform": { "min_version": "0.1.0" },
14
+ "capabilities": {
15
+ "frontend": true,
16
+ "backend": false,
17
+ "database": false,
18
+ "webhooks": false,
19
+ "scheduled_jobs": false,
20
+ "file_uploads": false,
21
+ "external_network": []
22
+ },
23
+ "frontend": {
24
+ "entry": "./frontend/src/index.tsx",
25
+ "sandbox": true,
26
+ "framework": "next",
27
+ "config": "./frontend/next.config.ts"
28
+ },
29
+ "permissions": []
30
+ }