@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.
- package/README.md +33 -3
- package/backend-sdk/palette_sdk/__init__.py +11 -1
- package/backend-sdk/palette_sdk/db/__init__.py +18 -5
- package/backend-sdk/palette_sdk/db/base.py +17 -0
- package/backend-sdk/palette_sdk/manifest.py +2 -0
- package/docs/python-backend-sdk.md +10 -8
- package/lib/bundler.js +200 -3
- package/lib/cli.js +1 -1
- package/lib/commands/build.js +42 -6
- package/lib/commands/dev.js +1 -1
- package/lib/commands/doctor.js +1 -1
- package/lib/commands/init.js +31 -7
- package/lib/commands/package.js +1 -1
- package/lib/commands/publish.js +1 -1
- package/lib/commands/test.js +8 -4
- package/lib/dev-simulator.js +4 -0
- package/lib/manifest.js +31 -1
- 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/backend/api/models.py +1 -1
- package/template-fallback/templates/database/backend/migrations/versions/001_init.py +5 -5
- 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/README.md +40 -0
- package/template-fallback/templates/next/frontend/next.config.ts +8 -0
- package/template-fallback/templates/next/frontend/src/index.tsx +30 -0
- package/template-fallback/templates/next/frontend/src/translations.ts +14 -0
- package/template-fallback/templates/next/frontend/tsconfig.json +15 -0
- package/template-fallback/templates/next/package.json +13 -0
- 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__ = "
|
|
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, "
|
|
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
|
|
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__ = "
|
|
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
|
-
"
|
|
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, "
|
|
32
|
+
ensure_org_rls(op, "my_app__expenses")
|
|
33
33
|
"""
|
|
34
34
|
|
|
35
|
-
from palette_sdk.db.base import
|
|
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__ = [
|
|
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
|
|
|
@@ -183,7 +183,7 @@ from palette_sdk import OrgScopedTable
|
|
|
183
183
|
|
|
184
184
|
|
|
185
185
|
class Invoice(OrgScopedTable):
|
|
186
|
-
__tablename__ = "
|
|
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.
|
|
195
|
-
|
|
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
|
-
"
|
|
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("
|
|
221
|
-
ensure_org_rls(op, "
|
|
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("
|
|
227
|
+
op.drop_table("finance_tools__invoices")
|
|
226
228
|
```
|
|
227
229
|
|
|
228
|
-
`ensure_org_rls(op, "
|
|
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 = {
|
|
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)")
|
package/lib/commands/build.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/lib/commands/dev.js
CHANGED
|
@@ -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: ${
|
package/lib/commands/doctor.js
CHANGED
|
@@ -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(
|
package/lib/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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_${
|
|
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
|
|
114
|
-
const
|
|
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.
|
|
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 ${
|
|
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`)
|
package/lib/commands/package.js
CHANGED
|
@@ -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
|
|
package/lib/commands/publish.js
CHANGED
|
@@ -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")
|
package/lib/commands/test.js
CHANGED
|
@@ -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:
|
|
503
|
-
{ re:
|
|
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)
|
package/lib/dev-simulator.js
CHANGED
|
@@ -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
|
@@ -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.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__ = "
|
|
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
|
-
"
|
|
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("
|
|
20
|
-
ensure_org_rls(op, "
|
|
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("
|
|
25
|
-
op.drop_table("
|
|
24
|
+
op.drop_index("ix_my_db_plugin__notes_org", table_name="my_db_plugin__notes")
|
|
25
|
+
op.drop_table("my_db_plugin__notes")
|
|
@@ -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.12", "backend": "^0.1.0" },
|
|
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.12", "backend": "^0.1.0" },
|
|
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.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,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,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
|
+
}
|