@palettelab/cli 0.3.7 → 0.3.9
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 +4 -4
- package/lib/commands/dev.js +19 -5
- package/lib/commands/publish.js +25 -17
- package/lib/commands/test.js +320 -4
- package/lib/environments.js +7 -1
- package/package.json +2 -6
- package/platform-dev/docker-compose.yml +1 -0
- package/template-fallback/README.md +20 -0
- package/template-fallback/backend/tests/test_import.py +11 -0
- package/template-fallback/pyproject.toml +10 -7
- package/template-fallback/templates/agent-tool/pyproject.toml +5 -1
- package/template-fallback/templates/dashboard/pyproject.toml +4 -1
- package/template-fallback/templates/database/pyproject.toml +6 -1
- package/template-fallback/templates/external-service/pyproject.toml +5 -1
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Your plugin directory is mounted into the container at `/plugins/<your-id>`. Edi
|
|
|
61
61
|
|
|
62
62
|
`3000` and `8000` are preferred defaults, not hard requirements. If either port is already in use, `pltt dev` automatically picks the next free port and prints the actual URLs.
|
|
63
63
|
|
|
64
|
-
`pltt dev --cloud` skips Docker and publishes a reviewable preview to a configured cloud sandbox. It defaults to `--env staging` unless `--env` or `PALETTE_ENV` is set.
|
|
64
|
+
`pltt dev --cloud` skips Docker and publishes a reviewable preview to a configured cloud sandbox. It defaults to `--env staging` unless `--env` or `PALETTE_ENV` is set, adds a 24-hour preview TTL by default, and prints the preview/status/log commands returned by the platform.
|
|
65
65
|
|
|
66
66
|
Environment variables:
|
|
67
67
|
|
|
@@ -100,7 +100,7 @@ pltt test
|
|
|
100
100
|
pltt test --json
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
Checks include manifest validity, frontend bundling, backend import, route permission gates, route permission declarations, migration linting, frontend sandbox policy, and dependency policy for `package.json` / `pyproject.toml`.
|
|
103
|
+
Checks include manifest validity, SDK/platform compatibility, semver bump detection, forbidden platform imports, frontend bundling and size limits, sandbox bridge smoke, backend dependency installation/import, route permission gates, route permission declarations, migration linting, frontend sandbox policy, and dependency policy for `package.json` / `pyproject.toml`.
|
|
104
104
|
|
|
105
105
|
### `pltt package`
|
|
106
106
|
|
|
@@ -171,5 +171,5 @@ If no plugin ID is provided, the CLI uses the current `palette-plugin.json` or `
|
|
|
171
171
|
## See also
|
|
172
172
|
|
|
173
173
|
- `@palettelab/sdk` on npm — frontend hooks and types
|
|
174
|
-
- `palette-sdk`
|
|
175
|
-
-
|
|
174
|
+
- `palette-sdk` GitHub Release wheel on `sdk-backend-v*` — backend `PluginRouter` + `ToolDefinition`
|
|
175
|
+
- `sdk/scripts/verify-release-registries.sh` — npmjs, GHCR, and backend SDK release verification
|
package/lib/commands/dev.js
CHANGED
|
@@ -77,16 +77,30 @@ async function run(args, { cwd }) {
|
|
|
77
77
|
const { flags, rest } = parseFlags(args)
|
|
78
78
|
const cloud = rest.includes("--cloud")
|
|
79
79
|
if (cloud) {
|
|
80
|
+
const json = args.includes("--json")
|
|
80
81
|
const publishArgs = []
|
|
81
82
|
if (flags.env) publishArgs.push("--env", flags.env)
|
|
82
83
|
if (flags.yes) publishArgs.push("--yes")
|
|
83
84
|
if (args.includes("--json")) publishArgs.push("--json")
|
|
85
|
+
if (Number.isFinite(flags.ttlHours) && flags.ttlHours > 0) {
|
|
86
|
+
publishArgs.push("--ttl-hours", String(flags.ttlHours))
|
|
87
|
+
} else {
|
|
88
|
+
publishArgs.push("--ttl-hours", "24")
|
|
89
|
+
}
|
|
84
90
|
if (!flags.env && !process.env.PALETTE_ENV) publishArgs.push("--env", "staging")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
if (!json) {
|
|
92
|
+
console.log(
|
|
93
|
+
"[pltt] cloud dev publishes a reviewable preview to the configured cloud sandbox.",
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
const record = await publish(publishArgs, { cwd })
|
|
97
|
+
if (!json && record?.preview_url) {
|
|
98
|
+
console.log(`[pltt] preview URL: ${record.preview_url}`)
|
|
99
|
+
}
|
|
100
|
+
if (!json && record?.id) {
|
|
101
|
+
console.log(`[pltt] status: pltt status ${record.id} --env ${flags.env || process.env.PALETTE_ENV || "staging"}`)
|
|
102
|
+
console.log("[pltt] logs: pltt logs --follow")
|
|
103
|
+
}
|
|
90
104
|
return
|
|
91
105
|
}
|
|
92
106
|
|
package/lib/commands/publish.js
CHANGED
|
@@ -79,6 +79,9 @@ async function put(url, buf, contentType) {
|
|
|
79
79
|
|
|
80
80
|
async function run(argv, { cwd }) {
|
|
81
81
|
const { flags } = parseFlags(argv)
|
|
82
|
+
const log = (...args) => {
|
|
83
|
+
if (!flags.json) console.log(...args)
|
|
84
|
+
}
|
|
82
85
|
|
|
83
86
|
let env
|
|
84
87
|
try {
|
|
@@ -115,27 +118,27 @@ async function run(argv, { cwd }) {
|
|
|
115
118
|
|
|
116
119
|
runPreflight(cwd, flags.json)
|
|
117
120
|
|
|
118
|
-
|
|
121
|
+
log(
|
|
119
122
|
`[pltt] publishing ${manifest.id}@${manifest.version} → ${env.name} (${env.url})`,
|
|
120
123
|
)
|
|
121
124
|
|
|
122
125
|
let frontend = null
|
|
123
126
|
if (manifest.frontend?.entry) {
|
|
124
|
-
|
|
127
|
+
log("[pltt] bundling frontend")
|
|
125
128
|
frontend = await bundleFrontend(cwd, manifest.frontend.entry)
|
|
126
|
-
|
|
129
|
+
log(`[pltt] ${frontend.length} bytes`)
|
|
127
130
|
} else {
|
|
128
|
-
|
|
131
|
+
log("[pltt] no frontend declared")
|
|
129
132
|
}
|
|
130
133
|
|
|
131
|
-
|
|
134
|
+
log("[pltt] bundling backend")
|
|
132
135
|
const backend = await bundleBackend(cwd)
|
|
133
|
-
|
|
136
|
+
log(`[pltt] ${backend.length} bytes`)
|
|
134
137
|
|
|
135
138
|
const backendSha = sha256(backend)
|
|
136
139
|
const api = makeApi(env)
|
|
137
140
|
|
|
138
|
-
|
|
141
|
+
log("[pltt] requesting signed URLs")
|
|
139
142
|
const signed = await api("/api/v1/appstore/sign-upload", {
|
|
140
143
|
method: "POST",
|
|
141
144
|
body: {
|
|
@@ -145,7 +148,7 @@ async function run(argv, { cwd }) {
|
|
|
145
148
|
},
|
|
146
149
|
})
|
|
147
150
|
|
|
148
|
-
|
|
151
|
+
log("[pltt] uploading")
|
|
149
152
|
const uploads = [
|
|
150
153
|
put(signed.backend_upload_url, backend, "application/gzip"),
|
|
151
154
|
put(
|
|
@@ -159,16 +162,20 @@ async function run(argv, { cwd }) {
|
|
|
159
162
|
}
|
|
160
163
|
await Promise.all(uploads)
|
|
161
164
|
|
|
162
|
-
|
|
165
|
+
log("[pltt] finalizing")
|
|
166
|
+
const publishBody = {
|
|
167
|
+
plugin_id: manifest.id,
|
|
168
|
+
version: manifest.version,
|
|
169
|
+
bundle_path: signed.bundle_path,
|
|
170
|
+
bundle_sha256: backendSha,
|
|
171
|
+
manifest,
|
|
172
|
+
}
|
|
173
|
+
if (Number.isFinite(flags.ttlHours) && flags.ttlHours > 0) {
|
|
174
|
+
publishBody.preview_ttl_hours = flags.ttlHours
|
|
175
|
+
}
|
|
163
176
|
const record = await api("/api/v1/appstore/publish", {
|
|
164
177
|
method: "POST",
|
|
165
|
-
body:
|
|
166
|
-
plugin_id: manifest.id,
|
|
167
|
-
version: manifest.version,
|
|
168
|
-
bundle_path: signed.bundle_path,
|
|
169
|
-
bundle_sha256: backendSha,
|
|
170
|
-
manifest,
|
|
171
|
-
},
|
|
178
|
+
body: publishBody,
|
|
172
179
|
})
|
|
173
180
|
|
|
174
181
|
try {
|
|
@@ -196,7 +203,7 @@ async function run(argv, { cwd }) {
|
|
|
196
203
|
|
|
197
204
|
if (flags.json) {
|
|
198
205
|
console.log(JSON.stringify(record, null, 2))
|
|
199
|
-
return
|
|
206
|
+
return record
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
console.log(
|
|
@@ -210,6 +217,7 @@ async function run(argv, { cwd }) {
|
|
|
210
217
|
console.log(`[pltt] preview: ${record.preview_url}`)
|
|
211
218
|
}
|
|
212
219
|
console.log(`[pltt] once approved, live at ${env.url}${record.catalog_url}`)
|
|
220
|
+
return record
|
|
213
221
|
}
|
|
214
222
|
|
|
215
223
|
module.exports = run
|
package/lib/commands/test.js
CHANGED
|
@@ -7,6 +7,9 @@ const { loadManifest, validateManifest, KNOWN_PERMISSIONS } = require("../manife
|
|
|
7
7
|
const { bundleFrontend, bundleBackend } = require("../bundler")
|
|
8
8
|
const buildCommand = require("./build")
|
|
9
9
|
|
|
10
|
+
const DEFAULT_FRONTEND_BUNDLE_LIMIT = 512 * 1024
|
|
11
|
+
const DEFAULT_BACKEND_BUNDLE_LIMIT = 5 * 1024 * 1024
|
|
12
|
+
|
|
10
13
|
function reporter(json, results) {
|
|
11
14
|
return {
|
|
12
15
|
ok(message, meta = {}) {
|
|
@@ -41,11 +44,94 @@ function backendPython(cwd) {
|
|
|
41
44
|
)
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
function
|
|
47
|
+
function localBackendSdkPath() {
|
|
48
|
+
const candidate = path.resolve(__dirname, "..", "..", "..", "backend")
|
|
49
|
+
return fs.existsSync(path.join(candidate, "palette_sdk")) ? candidate : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pythonEnv(extra = {}) {
|
|
53
|
+
const paths = []
|
|
54
|
+
if (extra.PYTHONPATH) paths.push(extra.PYTHONPATH)
|
|
55
|
+
if (process.env.PYTHONPATH) paths.push(process.env.PYTHONPATH)
|
|
56
|
+
const localSdk = localBackendSdkPath()
|
|
57
|
+
if (localSdk) paths.unshift(localSdk)
|
|
58
|
+
return {
|
|
59
|
+
...process.env,
|
|
60
|
+
...extra,
|
|
61
|
+
PYTHONPATH: paths.filter(Boolean).join(path.delimiter),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pyprojectDependencies(cwd) {
|
|
66
|
+
const pyprojectPath = path.join(cwd, "pyproject.toml")
|
|
67
|
+
if (!fs.existsSync(pyprojectPath)) return []
|
|
68
|
+
return extractPyprojectDependencies(fs.readFileSync(pyprojectPath, "utf8"))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function installPythonDependencies(cwd, out) {
|
|
72
|
+
const deps = pyprojectDependencies(cwd)
|
|
73
|
+
if (deps.length === 0) {
|
|
74
|
+
out.ok("no pyproject dependencies declared")
|
|
75
|
+
return { python: backendPython(cwd), env: pythonEnv(), failures: 0, dependencies: [] }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const hostPython = backendPython(cwd)
|
|
79
|
+
const venvDir = path.join(cwd, ".palette", "test-venv")
|
|
80
|
+
const venvPython = path.join(venvDir, "bin", "python")
|
|
81
|
+
const lockPath = path.join(venvDir, ".palette-deps-lock")
|
|
82
|
+
const lock = JSON.stringify(deps)
|
|
83
|
+
|
|
84
|
+
if (!fs.existsSync(venvPython)) {
|
|
85
|
+
const created = spawnSync(hostPython, ["-m", "venv", venvDir], {
|
|
86
|
+
cwd,
|
|
87
|
+
encoding: "utf8",
|
|
88
|
+
env: pythonEnv(),
|
|
89
|
+
})
|
|
90
|
+
if (created.status !== 0) {
|
|
91
|
+
return {
|
|
92
|
+
python: hostPython,
|
|
93
|
+
env: pythonEnv(),
|
|
94
|
+
failures: out.fail(
|
|
95
|
+
"could not create Python test virtualenv",
|
|
96
|
+
"Install Python's venv module or set PALETTE_PYTHON to a Python that can run `-m venv`.",
|
|
97
|
+
{ stderr: (created.stderr || created.stdout || "").trim() },
|
|
98
|
+
),
|
|
99
|
+
dependencies: deps,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!fs.existsSync(lockPath) || fs.readFileSync(lockPath, "utf8") !== lock) {
|
|
105
|
+
const installed = spawnSync(venvPython, ["-m", "pip", "install", ...deps], {
|
|
106
|
+
cwd,
|
|
107
|
+
encoding: "utf8",
|
|
108
|
+
env: pythonEnv(),
|
|
109
|
+
})
|
|
110
|
+
if (installed.status !== 0) {
|
|
111
|
+
return {
|
|
112
|
+
python: venvPython,
|
|
113
|
+
env: pythonEnv(),
|
|
114
|
+
failures: out.fail(
|
|
115
|
+
"could not install pyproject dependencies for backend import checks",
|
|
116
|
+
"Fix pyproject.toml dependencies or install them in .palette/test-venv before running pltt test.",
|
|
117
|
+
{ stderr: (installed.stderr || installed.stdout || "").trim(), dependencies: deps },
|
|
118
|
+
),
|
|
119
|
+
dependencies: deps,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
fs.writeFileSync(lockPath, lock)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
out.ok("pyproject dependencies installed for backend import checks", { dependencies: deps })
|
|
126
|
+
return { python: venvPython, env: pythonEnv(), failures: 0, dependencies: deps }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function checkBackendContract(cwd, manifest, out, pythonInfo) {
|
|
45
130
|
const entry = manifest.backend?.entry
|
|
46
131
|
if (!entry) return 0
|
|
47
132
|
const abs = path.resolve(cwd, entry)
|
|
48
|
-
const python = backendPython(cwd)
|
|
133
|
+
const python = pythonInfo?.python || backendPython(cwd)
|
|
134
|
+
const env = pythonInfo?.env || pythonEnv()
|
|
49
135
|
const script = [
|
|
50
136
|
"import importlib.util, json, pathlib, sys",
|
|
51
137
|
"entry = pathlib.Path(sys.argv[1]).resolve()",
|
|
@@ -86,7 +172,7 @@ function checkBackendContract(cwd, manifest, out) {
|
|
|
86
172
|
" ungated.append({'path': path, 'methods': methods})",
|
|
87
173
|
"print(json.dumps({'routes': routes, 'used_permissions': sorted(used), 'ungated_routes': ungated, 'undeclared_permissions': sorted(used - declared), 'unused_declared_permissions': sorted(declared - used)}))",
|
|
88
174
|
].join("\n")
|
|
89
|
-
const res = spawnSync(python, ["-c", script, abs, JSON.stringify(manifest)], { encoding: "utf8" })
|
|
175
|
+
const res = spawnSync(python, ["-c", script, abs, JSON.stringify(manifest)], { encoding: "utf8", env })
|
|
90
176
|
if (res.status === 0) {
|
|
91
177
|
let report
|
|
92
178
|
try {
|
|
@@ -220,6 +306,9 @@ function lintPackageJson(cwd, out) {
|
|
|
220
306
|
|
|
221
307
|
function pythonDependencyRisk(dep) {
|
|
222
308
|
const value = String(dep || "").trim()
|
|
309
|
+
const backendSdkRelease =
|
|
310
|
+
/^palette-sdk\s*@\s*https:\/\/github\.com\/palette-lab\/[-a-z0-9]+\/releases\/download\/sdk-backend-v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\/palette_sdk-\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:-py3-none-any\.whl|\.tar\.gz)$/i
|
|
311
|
+
if (backendSdkRelease.test(value)) return null
|
|
223
312
|
if (/@\s*(https?:|file:)|\bgit\+|^\s*(https?:|file:)/i.test(value)) {
|
|
224
313
|
return { level: "fail", reason: "dependency resolves from a local path, git repo, or URL" }
|
|
225
314
|
}
|
|
@@ -274,6 +363,224 @@ function lintDependencyPolicy(cwd, out) {
|
|
|
274
363
|
return lintPackageJson(cwd, out) + lintPyproject(cwd, out)
|
|
275
364
|
}
|
|
276
365
|
|
|
366
|
+
function parseVersion(version) {
|
|
367
|
+
const match = String(version || "").match(/^(\d+)\.(\d+)\.(\d+)/)
|
|
368
|
+
return match ? match.slice(1).map((n) => Number.parseInt(n, 10)) : null
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function compareVersions(a, b) {
|
|
372
|
+
const av = parseVersion(a)
|
|
373
|
+
const bv = parseVersion(b)
|
|
374
|
+
if (!av || !bv) return 0
|
|
375
|
+
for (let i = 0; i < 3; i += 1) {
|
|
376
|
+
if (av[i] !== bv[i]) return av[i] - bv[i]
|
|
377
|
+
}
|
|
378
|
+
return 0
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function versionSatisfies(version, range) {
|
|
382
|
+
if (!range) return true
|
|
383
|
+
const v = parseVersion(version)
|
|
384
|
+
if (!v) return false
|
|
385
|
+
const parts = String(range).trim().split(/\s+/)
|
|
386
|
+
if (parts.length > 1) return parts.every((part) => versionSatisfies(version, part))
|
|
387
|
+
const r = parts[0]
|
|
388
|
+
if (r === "*" || r.toLowerCase() === "latest") return false
|
|
389
|
+
if (r.startsWith("^")) {
|
|
390
|
+
const base = parseVersion(r.slice(1))
|
|
391
|
+
return !!base && v[0] === base[0] && compareVersions(version, r.slice(1)) >= 0
|
|
392
|
+
}
|
|
393
|
+
if (r.startsWith("~")) {
|
|
394
|
+
const base = parseVersion(r.slice(1))
|
|
395
|
+
return !!base && v[0] === base[0] && v[1] === base[1] && compareVersions(version, r.slice(1)) >= 0
|
|
396
|
+
}
|
|
397
|
+
if (r.startsWith(">=")) return compareVersions(version, r.slice(2)) >= 0
|
|
398
|
+
if (r.startsWith(">")) return compareVersions(version, r.slice(1)) > 0
|
|
399
|
+
if (r.startsWith("<=")) return compareVersions(version, r.slice(2)) <= 0
|
|
400
|
+
if (r.startsWith("<")) return compareVersions(version, r.slice(1)) < 0
|
|
401
|
+
return compareVersions(version, r) === 0
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function packageVersion(...candidates) {
|
|
405
|
+
for (const candidate of candidates) {
|
|
406
|
+
try {
|
|
407
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"))
|
|
408
|
+
if (pkg.version) return pkg.version
|
|
409
|
+
} catch (_err) {
|
|
410
|
+
// Try the next candidate.
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return null
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function backendSdkVersionFromDependency(cwd) {
|
|
417
|
+
for (const dep of pyprojectDependencies(cwd)) {
|
|
418
|
+
const release = String(dep).match(/sdk-backend-v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)
|
|
419
|
+
if (release) return release[1]
|
|
420
|
+
const constrained = String(dep).match(/^palette-sdk\s*(?:[<>=~!]=?\s*)?(\d+\.\d+\.\d+)/)
|
|
421
|
+
if (constrained) return constrained[1]
|
|
422
|
+
}
|
|
423
|
+
const localPyproject = path.resolve(__dirname, "..", "..", "..", "backend", "pyproject.toml")
|
|
424
|
+
if (fs.existsSync(localPyproject)) {
|
|
425
|
+
const match = fs.readFileSync(localPyproject, "utf8").match(/^version\s*=\s*["']([^"']+)["']/m)
|
|
426
|
+
if (match) return match[1]
|
|
427
|
+
}
|
|
428
|
+
return null
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function checkSdkCompatibility(cwd, manifest, out) {
|
|
432
|
+
let failures = 0
|
|
433
|
+
const frontendVersion = packageVersion(
|
|
434
|
+
path.join(cwd, "node_modules", "@palettelab", "sdk", "package.json"),
|
|
435
|
+
path.resolve(__dirname, "..", "..", "..", "frontend", "package.json"),
|
|
436
|
+
)
|
|
437
|
+
const backendVersion = backendSdkVersionFromDependency(cwd)
|
|
438
|
+
const platformVersion = process.env.PALETTE_PLATFORM_VERSION || "0.1.0"
|
|
439
|
+
|
|
440
|
+
if (manifest.sdk?.frontend) {
|
|
441
|
+
if (!frontendVersion || !versionSatisfies(frontendVersion, manifest.sdk.frontend)) {
|
|
442
|
+
failures += out.fail(
|
|
443
|
+
`frontend SDK compatibility failed: ${manifest.sdk.frontend}`,
|
|
444
|
+
"Install an @palettelab/sdk version that satisfies manifest.sdk.frontend.",
|
|
445
|
+
{ installed: frontendVersion, required: manifest.sdk.frontend },
|
|
446
|
+
)
|
|
447
|
+
} else {
|
|
448
|
+
out.ok("frontend SDK compatibility passed", { installed: frontendVersion, required: manifest.sdk.frontend })
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (manifest.sdk?.backend) {
|
|
453
|
+
if (!backendVersion || !versionSatisfies(backendVersion, manifest.sdk.backend)) {
|
|
454
|
+
failures += out.fail(
|
|
455
|
+
`backend SDK compatibility failed: ${manifest.sdk.backend}`,
|
|
456
|
+
"Pin palette-sdk to a GitHub Release wheel that satisfies manifest.sdk.backend.",
|
|
457
|
+
{ installed: backendVersion, required: manifest.sdk.backend },
|
|
458
|
+
)
|
|
459
|
+
} else {
|
|
460
|
+
out.ok("backend SDK compatibility passed", { installed: backendVersion, required: manifest.sdk.backend })
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (manifest.platform?.min_version || manifest.platform?.max_version) {
|
|
465
|
+
const minOk = !manifest.platform.min_version || versionSatisfies(platformVersion, `>=${manifest.platform.min_version}`)
|
|
466
|
+
const maxOk = !manifest.platform.max_version || versionSatisfies(platformVersion, `<=${manifest.platform.max_version}`)
|
|
467
|
+
if (!minOk || !maxOk) {
|
|
468
|
+
failures += out.fail(
|
|
469
|
+
"platform compatibility failed",
|
|
470
|
+
"Adjust manifest.platform or test against a compatible Palette platform version.",
|
|
471
|
+
{ platform: platformVersion, required: manifest.platform },
|
|
472
|
+
)
|
|
473
|
+
} else {
|
|
474
|
+
out.ok("platform compatibility passed", { platform: platformVersion, required: manifest.platform })
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return failures
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function scanForbiddenImports(cwd, manifest, out) {
|
|
482
|
+
const roots = []
|
|
483
|
+
if (manifest.frontend?.entry) roots.push(path.resolve(cwd, "frontend"))
|
|
484
|
+
if (manifest.backend?.entry) roots.push(path.resolve(cwd, "backend"))
|
|
485
|
+
const forbidden = [
|
|
486
|
+
{ re: /from\s+["'](?:@\/|app\/|backend\/|frontend\/)/, reason: "frontend imports platform source" },
|
|
487
|
+
{ re: /import\s+["'](?:@\/|app\/|backend\/|frontend\/)/, reason: "frontend imports platform source" },
|
|
488
|
+
{ re: /^\s*(?:from|import)\s+app(?:\.|\s)/m, reason: "backend imports platform app source" },
|
|
489
|
+
]
|
|
490
|
+
const issues = []
|
|
491
|
+
const visit = (dir) => {
|
|
492
|
+
if (!fs.existsSync(dir)) return
|
|
493
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
494
|
+
if (["node_modules", ".venv", ".palette", "dist", "__pycache__"].includes(entry.name)) continue
|
|
495
|
+
const abs = path.join(dir, entry.name)
|
|
496
|
+
if (entry.isDirectory()) {
|
|
497
|
+
visit(abs)
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
500
|
+
if (!/\.(tsx?|jsx?|py)$/.test(entry.name)) continue
|
|
501
|
+
const src = fs.readFileSync(abs, "utf8")
|
|
502
|
+
for (const rule of forbidden) {
|
|
503
|
+
if (rule.re.test(src)) issues.push({ file: path.relative(cwd, abs), reason: rule.reason })
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
for (const root of roots) visit(root)
|
|
508
|
+
for (const issue of issues) {
|
|
509
|
+
out.fail(
|
|
510
|
+
`forbidden platform import in ${issue.file}`,
|
|
511
|
+
"Plugins must import only public SDK packages and their own local files.",
|
|
512
|
+
issue,
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
if (issues.length === 0) out.ok("forbidden platform import scan passed")
|
|
516
|
+
return issues.length
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function checkSemverBump(cwd, manifest, out) {
|
|
520
|
+
const statePath = path.join(cwd, ".palette", "last-publish.json")
|
|
521
|
+
if (!fs.existsSync(statePath)) {
|
|
522
|
+
out.ok("semver bump check skipped; no previous publish state")
|
|
523
|
+
return 0
|
|
524
|
+
}
|
|
525
|
+
let previous
|
|
526
|
+
try {
|
|
527
|
+
previous = JSON.parse(fs.readFileSync(statePath, "utf8"))
|
|
528
|
+
} catch (_err) {
|
|
529
|
+
out.warn("semver bump check skipped; .palette/last-publish.json is invalid JSON")
|
|
530
|
+
return 0
|
|
531
|
+
}
|
|
532
|
+
const previousVersion = previous.version || previous.manifest?.version
|
|
533
|
+
if (!previousVersion) {
|
|
534
|
+
out.ok("semver bump check skipped; previous publish has no version")
|
|
535
|
+
return 0
|
|
536
|
+
}
|
|
537
|
+
if (compareVersions(manifest.version, previousVersion) <= 0) {
|
|
538
|
+
return out.fail(
|
|
539
|
+
`plugin version ${manifest.version} is not newer than last publish ${previousVersion}`,
|
|
540
|
+
"Bump manifest.version before publishing another build.",
|
|
541
|
+
{ current: manifest.version, previous: previousVersion },
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
out.ok("semver bump check passed", { current: manifest.version, previous: previousVersion })
|
|
545
|
+
return 0
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function bundleLimit(kind) {
|
|
549
|
+
const envName = kind === "frontend" ? "PALETTE_MAX_FRONTEND_BUNDLE_BYTES" : "PALETTE_MAX_BACKEND_BUNDLE_BYTES"
|
|
550
|
+
const fallback = kind === "frontend" ? DEFAULT_FRONTEND_BUNDLE_LIMIT : DEFAULT_BACKEND_BUNDLE_LIMIT
|
|
551
|
+
const parsed = Number.parseInt(process.env[envName] || "", 10)
|
|
552
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function checkBundleSize(kind, bytes, out) {
|
|
556
|
+
const limit = bundleLimit(kind)
|
|
557
|
+
if (bytes > limit) {
|
|
558
|
+
return out.fail(
|
|
559
|
+
`${kind} bundle exceeds size limit (${bytes} > ${limit} bytes)`,
|
|
560
|
+
`Reduce bundle size or set PALETTE_MAX_${kind.toUpperCase()}_BUNDLE_BYTES for a reviewed exception.`,
|
|
561
|
+
{ bytes, limit },
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
out.ok(`${kind} bundle size within limit`, { bytes, limit })
|
|
565
|
+
return 0
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function sandboxBridgeSmoke(cwd, manifest, out) {
|
|
569
|
+
if (!manifest.frontend?.entry) return 0
|
|
570
|
+
const entry = path.resolve(cwd, manifest.frontend.entry)
|
|
571
|
+
if (!fs.existsSync(entry)) return 0
|
|
572
|
+
const src = fs.readFileSync(entry, "utf8")
|
|
573
|
+
if (!/@palettelab\/sdk/.test(src)) {
|
|
574
|
+
out.warn(
|
|
575
|
+
"sandbox bridge smoke skipped; frontend does not import @palettelab/sdk",
|
|
576
|
+
"Use @palettelab/sdk helpers so sandboxed appstore runtime can communicate through the platform bridge.",
|
|
577
|
+
)
|
|
578
|
+
return 0
|
|
579
|
+
}
|
|
580
|
+
out.ok("sandbox bridge smoke passed")
|
|
581
|
+
return 0
|
|
582
|
+
}
|
|
583
|
+
|
|
277
584
|
async function run(args, { cwd }) {
|
|
278
585
|
const json = args.includes("--json")
|
|
279
586
|
let failures = 0
|
|
@@ -300,6 +607,10 @@ async function run(args, { cwd }) {
|
|
|
300
607
|
out.ok(`manifest valid: ${manifest.id}@${manifest.version}`)
|
|
301
608
|
}
|
|
302
609
|
|
|
610
|
+
failures += checkSdkCompatibility(cwd, manifest, out)
|
|
611
|
+
failures += scanForbiddenImports(cwd, manifest, out)
|
|
612
|
+
failures += checkSemverBump(cwd, manifest, out)
|
|
613
|
+
|
|
303
614
|
for (const permission of manifest.permissions || []) {
|
|
304
615
|
if (!KNOWN_PERMISSIONS.has(permission)) {
|
|
305
616
|
failures += out.fail(
|
|
@@ -330,6 +641,8 @@ async function run(args, { cwd }) {
|
|
|
330
641
|
try {
|
|
331
642
|
const frontend = await bundleFrontend(cwd, manifest.frontend.entry)
|
|
332
643
|
out.ok(`frontend bundles successfully (${frontend.length} bytes)`, { bytes: frontend.length })
|
|
644
|
+
failures += checkBundleSize("frontend", frontend.length, out)
|
|
645
|
+
failures += sandboxBridgeSmoke(cwd, manifest, out)
|
|
333
646
|
} catch (err) {
|
|
334
647
|
failures += out.fail(
|
|
335
648
|
`frontend bundle failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -339,17 +652,20 @@ async function run(args, { cwd }) {
|
|
|
339
652
|
}
|
|
340
653
|
|
|
341
654
|
if (manifest.backend?.entry) {
|
|
655
|
+
const pythonInfo = installPythonDependencies(cwd, out)
|
|
656
|
+
failures += pythonInfo.failures
|
|
342
657
|
const backendEntry = path.resolve(cwd, manifest.backend.entry)
|
|
343
658
|
if (!fs.existsSync(backendEntry)) {
|
|
344
659
|
failures += out.fail(`backend entry not found: ${manifest.backend.entry}`)
|
|
345
660
|
} else {
|
|
346
661
|
out.ok(`backend entry exists: ${manifest.backend.entry}`)
|
|
347
|
-
failures += checkBackendContract(cwd, manifest, out)
|
|
662
|
+
failures += checkBackendContract(cwd, manifest, out, pythonInfo)
|
|
348
663
|
}
|
|
349
664
|
|
|
350
665
|
try {
|
|
351
666
|
const backend = await bundleBackend(cwd)
|
|
352
667
|
out.ok(`backend package builds successfully (${backend.length} bytes)`, { bytes: backend.length })
|
|
668
|
+
failures += checkBundleSize("backend", backend.length, out)
|
|
353
669
|
} catch (err) {
|
|
354
670
|
failures += out.fail(
|
|
355
671
|
`backend package failed: ${err instanceof Error ? err.message : String(err)}`,
|
package/lib/environments.js
CHANGED
|
@@ -130,7 +130,7 @@ function resolveEnvironment({ cwd, flags }) {
|
|
|
130
130
|
* Leaves positional args in `rest`.
|
|
131
131
|
*/
|
|
132
132
|
function parseFlags(argv) {
|
|
133
|
-
const flags = { env: undefined, yes: false }
|
|
133
|
+
const flags = { env: undefined, yes: false, ttlHours: undefined, wait: false }
|
|
134
134
|
const rest = []
|
|
135
135
|
for (let i = 0; i < argv.length; i++) {
|
|
136
136
|
const a = argv[i]
|
|
@@ -140,6 +140,12 @@ function parseFlags(argv) {
|
|
|
140
140
|
flags.env = a.slice("--env=".length)
|
|
141
141
|
} else if (a === "--yes" || a === "-y") {
|
|
142
142
|
flags.yes = true
|
|
143
|
+
} else if (a === "--ttl-hours") {
|
|
144
|
+
flags.ttlHours = Number.parseInt(argv[++i] || "", 10)
|
|
145
|
+
} else if (a.startsWith("--ttl-hours=")) {
|
|
146
|
+
flags.ttlHours = Number.parseInt(a.slice("--ttl-hours=".length), 10)
|
|
147
|
+
} else if (a === "--wait") {
|
|
148
|
+
flags.wait = true
|
|
143
149
|
} else {
|
|
144
150
|
rest.push(a)
|
|
145
151
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@palettelab/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Developer CLI for building Palette platform plugins — no platform source access required.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pltt": "bin/pltt.js"
|
|
@@ -24,11 +24,7 @@
|
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"registry": "https://registry.npmjs.org"
|
|
26
26
|
},
|
|
27
|
-
"
|
|
28
|
-
"type": "git",
|
|
29
|
-
"url": "git+https://github.com/palette-lab/virtual-organisation.git",
|
|
30
|
-
"directory": "sdk/cli-npm"
|
|
31
|
-
},
|
|
27
|
+
"homepage": "https://www.npmjs.com/package/@palettelab/cli",
|
|
32
28
|
"license": "MIT",
|
|
33
29
|
"keywords": [
|
|
34
30
|
"pltt",
|
|
@@ -21,6 +21,7 @@ services:
|
|
|
21
21
|
JWT_SECRET: "dev-secret-do-not-use-in-prod"
|
|
22
22
|
FRONTEND_URL: "http://localhost:${PALETTE_FRONTEND_PORT:-3000}"
|
|
23
23
|
BACKEND_BASE_URL: "http://localhost:${PALETTE_BACKEND_PORT:-8000}"
|
|
24
|
+
NEXT_PUBLIC_API_URL: "http://localhost:${PALETTE_BACKEND_PORT:-8000}"
|
|
24
25
|
STORAGE_BACKEND: "local"
|
|
25
26
|
LOCAL_STORAGE_DIR: "/srv/storage"
|
|
26
27
|
# Disable optional features that need real credentials
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Palette Plugin Template
|
|
2
|
+
|
|
3
|
+
Run the local contract gate before publishing:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pltt build
|
|
7
|
+
pltt test --json
|
|
8
|
+
pltt package --json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Expected preview flow:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pltt dev
|
|
15
|
+
pltt dev --cloud --env staging
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`pltt dev` opens the app locally at `/apps/<plugin-id>`. `pltt dev --cloud`
|
|
19
|
+
publishes a temporary staging preview, returns the platform preview URL, and
|
|
20
|
+
lets you poll review state with `pltt status <publish-id>`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_backend_entry_exports_router():
|
|
6
|
+
entry = Path(__file__).resolve().parents[1] / "api" / "main.py"
|
|
7
|
+
spec = importlib.util.spec_from_file_location("plugin_backend_entry", entry)
|
|
8
|
+
assert spec and spec.loader
|
|
9
|
+
module = importlib.util.module_from_spec(spec)
|
|
10
|
+
spec.loader.exec_module(module)
|
|
11
|
+
assert getattr(module, "router", None) is not None
|
|
@@ -3,13 +3,16 @@ name = "palette-plugin-my-plugin"
|
|
|
3
3
|
version = "1.0.0"
|
|
4
4
|
description = "A Palette platform plugin"
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
|
-
# palette-sdk is
|
|
7
|
-
# platform-dev container already has it on PYTHONPATH, so you do not
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# (replace $GH_PAT with your GitHub PAT with read:packages):
|
|
6
|
+
# palette-sdk is distributed as a GitHub Release wheel on sdk-backend-vX.Y.Z.
|
|
7
|
+
# The platform-dev container already has it on PYTHONPATH, so you do not need
|
|
8
|
+
# to install it locally to run `pltt dev`. If you want to run pytest or IDE
|
|
9
|
+
# imports outside the container, pin the backend SDK version you target:
|
|
11
10
|
#
|
|
12
11
|
# dependencies = [
|
|
13
|
-
# "palette-sdk @
|
|
12
|
+
# "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-vX.Y.Z/palette_sdk-X.Y.Z-py3-none-any.whl",
|
|
14
13
|
# ]
|
|
15
|
-
dependencies = [
|
|
14
|
+
dependencies = [
|
|
15
|
+
"fastapi>=0.129.0",
|
|
16
|
+
"sqlalchemy>=2.0.47",
|
|
17
|
+
# "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-vX.Y.Z/palette_sdk-X.Y.Z-py3-none-any.whl",
|
|
18
|
+
]
|
|
@@ -2,4 +2,8 @@
|
|
|
2
2
|
name = "my-agent-tool"
|
|
3
3
|
version = "1.0.0"
|
|
4
4
|
requires-python = ">=3.12"
|
|
5
|
-
dependencies = [
|
|
5
|
+
dependencies = [
|
|
6
|
+
"fastapi>=0.129.0",
|
|
7
|
+
"pydantic>=2.12.0",
|
|
8
|
+
# "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-vX.Y.Z/palette_sdk-X.Y.Z-py3-none-any.whl",
|
|
9
|
+
]
|
|
@@ -2,4 +2,7 @@
|
|
|
2
2
|
name = "my-dashboard"
|
|
3
3
|
version = "1.0.0"
|
|
4
4
|
requires-python = ">=3.12"
|
|
5
|
-
dependencies = [
|
|
5
|
+
dependencies = [
|
|
6
|
+
"fastapi>=0.129.0",
|
|
7
|
+
# "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-vX.Y.Z/palette_sdk-X.Y.Z-py3-none-any.whl",
|
|
8
|
+
]
|
|
@@ -2,4 +2,9 @@
|
|
|
2
2
|
name = "my-db-plugin"
|
|
3
3
|
version = "1.0.0"
|
|
4
4
|
requires-python = ">=3.12"
|
|
5
|
-
dependencies = [
|
|
5
|
+
dependencies = [
|
|
6
|
+
"fastapi>=0.129.0",
|
|
7
|
+
"sqlalchemy>=2.0.47",
|
|
8
|
+
"alembic>=1.17.0",
|
|
9
|
+
# "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-vX.Y.Z/palette_sdk-X.Y.Z-py3-none-any.whl",
|
|
10
|
+
]
|
|
@@ -2,4 +2,8 @@
|
|
|
2
2
|
name = "my-external-svc"
|
|
3
3
|
version = "1.0.0"
|
|
4
4
|
requires-python = ">=3.12"
|
|
5
|
-
dependencies = [
|
|
5
|
+
dependencies = [
|
|
6
|
+
"fastapi>=0.129.0",
|
|
7
|
+
"httpx>=0.28.0",
|
|
8
|
+
# "palette-sdk @ https://github.com/palette-lab/palette-virtual-organization-backend/releases/download/sdk-backend-vX.Y.Z/palette_sdk-X.Y.Z-py3-none-any.whl",
|
|
9
|
+
]
|