@palettelab/cli 0.3.8 → 0.3.10

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 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` (git-installed from [palette-lab/virtual-organisation](https://github.com/palette-lab/virtual-organisation/tree/main/sdk/backend)) — backend `PluginRouter` + `ToolDefinition`
175
- - [Developer Guide](https://github.com/palette-lab/virtual-organisation/blob/main/sdk/DEVELOPER_GUIDE.md)
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
@@ -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
- console.log(
86
- "[pltt] cloud dev publishes a reviewable preview to the configured cloud sandbox.",
87
- )
88
- console.log("[pltt] use `pltt status <publish-id>` after upload to track review state.")
89
- await publish(publishArgs, { cwd })
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
 
@@ -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
- console.log(
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
- console.log("[pltt] bundling frontend")
127
+ log("[pltt] bundling frontend")
125
128
  frontend = await bundleFrontend(cwd, manifest.frontend.entry)
126
- console.log(`[pltt] ${frontend.length} bytes`)
129
+ log(`[pltt] ${frontend.length} bytes`)
127
130
  } else {
128
- console.log("[pltt] no frontend declared")
131
+ log("[pltt] no frontend declared")
129
132
  }
130
133
 
131
- console.log("[pltt] bundling backend")
134
+ log("[pltt] bundling backend")
132
135
  const backend = await bundleBackend(cwd)
133
- console.log(`[pltt] ${backend.length} bytes`)
136
+ log(`[pltt] ${backend.length} bytes`)
134
137
 
135
138
  const backendSha = sha256(backend)
136
139
  const api = makeApi(env)
137
140
 
138
- console.log("[pltt] requesting signed URLs")
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
- console.log("[pltt] uploading")
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
- console.log("[pltt] finalizing")
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
@@ -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 checkBackendContract(cwd, manifest, out) {
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)}`,
@@ -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/lib/ports.js CHANGED
@@ -13,13 +13,38 @@ function canBindPort(port, host = "0.0.0.0") {
13
13
  })
14
14
  }
15
15
 
16
+ function canConnectPort(port, host, timeoutMs = 250) {
17
+ return new Promise((resolve) => {
18
+ const socket = net.createConnection({ port, host })
19
+ const done = (connected) => {
20
+ socket.removeAllListeners()
21
+ socket.destroy()
22
+ resolve(connected)
23
+ }
24
+ socket.setTimeout(timeoutMs)
25
+ socket.once("connect", () => done(true))
26
+ socket.once("timeout", () => done(false))
27
+ socket.once("error", () => done(false))
28
+ })
29
+ }
30
+
31
+ async function isPortAvailable(port, { bindHost = "0.0.0.0" } = {}) {
32
+ // Docker publishes host ports on localhost-facing addresses. A process bound
33
+ // only to 127.0.0.1 or ::1 can be missed by a single 0.0.0.0 bind probe on
34
+ // some host setups, so test the addresses users actually open in browsers.
35
+ for (const host of ["127.0.0.1", "::1", "localhost"]) {
36
+ if (await canConnectPort(port, host)) return false
37
+ }
38
+ return canBindPort(port, bindHost)
39
+ }
40
+
16
41
  async function findFreePort(preferred, { host = "0.0.0.0", maxAttempts = 100 } = {}) {
17
42
  const start = Number(preferred)
18
43
  if (!Number.isInteger(start) || start <= 0 || start > 65535) {
19
44
  throw new Error(`invalid port: ${preferred}`)
20
45
  }
21
46
  for (let port = start; port < start + maxAttempts && port <= 65535; port++) {
22
- if (await canBindPort(port, host)) return port
47
+ if (await isPortAvailable(port, { bindHost: host })) return port
23
48
  }
24
49
  throw new Error(`no free port found from ${start} to ${Math.min(start + maxAttempts - 1, 65535)}`)
25
50
  }
@@ -39,4 +64,4 @@ async function resolveDevPorts({
39
64
  }
40
65
  }
41
66
 
42
- module.exports = { canBindPort, findFreePort, resolveDevPorts }
67
+ module.exports = { canBindPort, canConnectPort, isPortAvailable, findFreePort, resolveDevPorts }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
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
- "repository": {
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",
@@ -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 installed from the private platform repo. The
7
- # platform-dev container already has it on PYTHONPATH, so you do not
8
- # need to install it locally to run `pltt dev`. If you want to run
9
- # `pytest` on the backend outside the container, add this dependency
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 @ git+https://${GH_PAT}@github.com/palette-lab/virtual-organisation.git#subdirectory=sdk/backend",
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 = ["palette-sdk", "pydantic"]
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 = ["palette-sdk"]
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 = ["palette-sdk", "sqlalchemy", "alembic"]
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 = ["palette-sdk", "httpx"]
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
+ ]