@palettelab/cli 0.3.53 → 0.3.55

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
@@ -396,6 +396,56 @@ The CLI validates manifest shape, SDK compatibility, frontend bundling, backend
396
396
  imports, backend route permission gates, declared permissions, migration safety,
397
397
  package dependency policy, and backend package size.
398
398
 
399
+ ## OS Notifications
400
+
401
+ Apps can push persistent notifications into the Palette OS notification center
402
+ (the bell) with `@palettelab/sdk@0.1.25+`:
403
+
404
+ ```ts
405
+ import { notifications } from "@palettelab/sdk"
406
+ // or: palette.notifications.push(...)
407
+
408
+ await notifications.push({
409
+ title: "Export complete",
410
+ body: "Your report is ready to download.",
411
+ severity: "success", // "info" | "success" | "warning" | "error"
412
+ route: "/exports/123", // opened inside your app on click
413
+ })
414
+ ```
415
+
416
+ The notification shows as a live toast plus a notification-center entry;
417
+ clicking it opens/focuses your app window at the resolved route. The stable
418
+ contract is `POST /api/v1/notifications/` on the platform API (delivery streams
419
+ over `GET /api/v1/notifications/stream`, SSE).
420
+
421
+ Python plugin backends can push too, via `ctx.notifications` (palette-sdk
422
+ `0.1.9+`):
423
+
424
+ ```python
425
+ from palette_sdk import PluginContext, get_plugin_context, require_permission
426
+
427
+ @router.post("/exports", dependencies=[require_permission("resources:write")])
428
+ async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
429
+ ...
430
+ await ctx.notifications.push(
431
+ "Export complete",
432
+ body="Your report is ready to download.",
433
+ severity="success",
434
+ route="/exports/123",
435
+ )
436
+ ```
437
+
438
+ By default the backend helper notifies the user making the current request;
439
+ pass `user_id` to notify another member of the same organisation. See
440
+ `docs/python-backend-sdk.md` ("OS Notifications From Python") for details.
441
+
442
+ During `pltt dev`, frontend pushes call the platform backend directly
443
+ (`NEXT_PUBLIC_API_URL`, default `http://localhost:8000`) in the user's session,
444
+ so they land in the real notification pool. Backend pushes run against the
445
+ local simulator, which logs them as `[palette-notification] {...}` and returns
446
+ a stub. Sandboxed iframe apps are not supported yet (no session cookie inside
447
+ the iframe).
448
+
399
449
  ## Commands
400
450
 
401
451
  ### `pltt init <name>`
@@ -502,11 +552,14 @@ work without Docker or platform source. The terminal streams local frontend
502
552
  requests, frontend rebuilds, and backend process output while `pltt dev` is
503
553
  running.
504
554
 
505
- The simulator also provides the same language fields that Palette OS provides:
506
- `language`, `fallbackLanguage`, `supportedLanguages`, and `setLanguage()`.
507
- Generated frontend templates include app-owned `frontend/src/translations.ts`
508
- files wired through `usePluginTranslations()`, so apps can switch when the OS
509
- language changes without storing copy in the platform.
555
+ The simulator also provides the same OS context fields that Palette OS provides:
556
+ `language`, `fallbackLanguage`, `supportedLanguages`, `setLanguage()`,
557
+ `colorMode`, and `setColorMode()`. Generated frontend templates include
558
+ app-owned `frontend/src/translations.ts` files wired through
559
+ `usePluginTranslations()`, so apps can switch when the OS language changes
560
+ without storing copy in the platform. Apps can read `usePlatform().colorMode`
561
+ or listen for the `palette:theme-change` browser event to react when Palette OS
562
+ changes between light and dark mode.
510
563
 
511
564
  For Python apps with database tables, `ctx.db` is an async SQLAlchemy session in
512
565
  local dev. The simulator imports `backend/api/models.py` when present and creates
@@ -6,6 +6,7 @@ from palette_sdk.data_rooms import DataRoomsClient
6
6
  from palette_sdk.connections import ConnectionStatus, MissingConnectionError, PluginConnectionsClient
7
7
  from palette_sdk.apps import AppInteropClient, AppServiceClient, MissingAppServiceError
8
8
  from palette_sdk.members import OrganizationMembersClient
9
+ from palette_sdk.notifications import NotificationsClient
9
10
  from palette_sdk.platform_services import (
10
11
  LocalRedisService,
11
12
  LocalVectorService,
@@ -62,6 +63,7 @@ __all__ = [
62
63
  "AppServiceClient",
63
64
  "MissingAppServiceError",
64
65
  "OrganizationMembersClient",
66
+ "NotificationsClient",
65
67
  "LocalRedisService",
66
68
  "LocalVectorService",
67
69
  "PlatformServiceUnavailable",
@@ -0,0 +1,67 @@
1
+ """Backend OS notification helpers for plugin Python code.
2
+
3
+ Push a persistent notification into the OS notification center from a plugin
4
+ backend — the Python counterpart of the frontend SDK's `notifications.push()`:
5
+
6
+ from palette_sdk import PluginContext, get_plugin_context
7
+
8
+ @router.post("/exports", dependencies=[require_permission("resources:write")])
9
+ async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
10
+ ...
11
+ await ctx.notifications.push(
12
+ "Export complete",
13
+ body="Your report is ready to download.",
14
+ severity="success",
15
+ route="/exports/123",
16
+ )
17
+
18
+ `route` resolves relative to the calling app (`/apps/{plugin_id}/exports/123`);
19
+ pass an absolute `/apps/...` route to target another app's window. By default
20
+ the notification goes to the user making the current request; pass `user_id`
21
+ to notify a different member of the same organisation (the platform rejects
22
+ targets outside the caller's org).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+
30
+ class NotificationsClient:
31
+ """Thin wrapper around the platform-injected notification service.
32
+
33
+ In production/hosted sandbox the platform injects the service into
34
+ `PluginContext`. In local unit tests, pass a fake service with the same
35
+ async `push` method.
36
+ """
37
+
38
+ def __init__(self, service: Any = None):
39
+ self._service = service
40
+
41
+ def _require_service(self) -> Any:
42
+ if self._service is None:
43
+ raise RuntimeError(
44
+ "Notification service is not available in this runtime. "
45
+ "Run inside Palette OS/hosted sandbox or inject a fake service in tests."
46
+ )
47
+ return self._service
48
+
49
+ async def push(
50
+ self,
51
+ title: str,
52
+ *,
53
+ body: str | None = None,
54
+ route: str | None = None,
55
+ severity: str | None = None,
56
+ data: dict[str, Any] | None = None,
57
+ user_id: str | None = None,
58
+ ) -> dict[str, Any]:
59
+ """Push a notification; returns the serialized notification dict."""
60
+ return await self._require_service().push(
61
+ title=title,
62
+ body=body,
63
+ route=route,
64
+ severity=severity,
65
+ data=data,
66
+ user_id=user_id,
67
+ )
@@ -14,6 +14,7 @@ from palette_sdk.data_rooms import DataRoomsClient
14
14
  from palette_sdk.connections import PluginConnectionsClient
15
15
  from palette_sdk.apps import AppInteropClient
16
16
  from palette_sdk.members import OrganizationMembersClient
17
+ from palette_sdk.notifications import NotificationsClient
17
18
  from palette_sdk.platform_services import UnavailablePlatformService
18
19
  from palette_sdk.events import EventPublisher
19
20
 
@@ -40,6 +41,7 @@ class PluginContext:
40
41
  permissions: List of permissions declared in the manifest
41
42
  storage: Storage service for file upload/download
42
43
  members: Organization member helpers for the current org
44
+ notifications: OS notification center push helpers
43
45
  """
44
46
  db: AsyncSession
45
47
  user_id: str
@@ -52,6 +54,7 @@ class PluginContext:
52
54
  connections: PluginConnectionsClient = field(default_factory=PluginConnectionsClient)
53
55
  apps: AppInteropClient = field(default_factory=AppInteropClient)
54
56
  members: OrganizationMembersClient = field(default_factory=OrganizationMembersClient)
57
+ notifications: NotificationsClient = field(default_factory=NotificationsClient)
55
58
  redis: Any = field(default_factory=lambda: UnavailablePlatformService("redis"))
56
59
  vector: Any = field(default_factory=lambda: UnavailablePlatformService("vector"))
57
60
  events: EventPublisher = field(default_factory=EventPublisher)
@@ -128,6 +131,7 @@ async def get_plugin_context(request: Request) -> PluginContext:
128
131
  getattr(state, "org_members", None),
129
132
  getattr(state, "plugin_permissions", []),
130
133
  ),
134
+ notifications=NotificationsClient(getattr(state, "notifications", None)),
131
135
  redis=getattr(state, "redis", None) or UnavailablePlatformService("redis"),
132
136
  vector=getattr(state, "vector", None) or UnavailablePlatformService("vector"),
133
137
  events=EventPublisher(getattr(state, "plugin_events", None)),
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "palette-sdk"
3
- version = "0.1.8"
3
+ version = "0.1.9"
4
4
  description = "Palette Platform SDK for building backend plugins"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -114,6 +114,7 @@ Available context values:
114
114
  | `ctx.data_rooms` | Backend Data Room client |
115
115
  | `ctx.connections` | Palette-managed third-party connection client |
116
116
  | `ctx.members` | Current organisation member client |
117
+ | `ctx.notifications` | Push OS notification-center notifications |
117
118
  | `ctx.apps` / `services(ctx)` | Governed app-to-app calls through declared `consumes` contracts |
118
119
  | `ctx.events` | Publish event topics declared in `provides.events` |
119
120
  | `ctx.redis` | Plugin/org-scoped Redis-style service when `platform_services` includes `redis` |
@@ -141,6 +142,7 @@ These are the public Python helpers exported by `palette_sdk`.
141
142
  | `KNOWN_PERMISSIONS`, `is_known_permission(...)` | Permission vocabulary checks for manifests/tools |
142
143
  | `DataRoomsClient`, `ctx.data_rooms` | Backend Data Room room/folder/file helpers |
143
144
  | `OrganizationMembersClient`, `ctx.members` | Current-organization member lookup, invite, and role helpers |
145
+ | `NotificationsClient`, `ctx.notifications` | Push notifications into the OS notification center |
144
146
  | `PluginConnectionsClient`, `ConnectionStatus`, `MissingConnectionError`, `ctx.connections` | Third-party connection status and token helpers |
145
147
  | `AppInteropClient`, `AppServiceClient`, `MissingAppServiceError`, `ctx.apps` | Call required apps/services without direct database access |
146
148
  | `service(...)`, `services(ctx)`, `ServicesClient`, `BrokerCallError` | Provide and consume OS-broker service methods/events |
@@ -592,6 +594,50 @@ In local unit tests outside the Palette runtime, inject a fake Data Room service
592
594
  if you need to test routes that call `ctx.data_rooms`. In hosted sandbox and
593
595
  real OS runtime, the platform injects the real service.
594
596
 
597
+ ## 8a. OS Notifications From Python
598
+
599
+ Backend code can push persistent notifications into the OS notification
600
+ center (the bell) — the Python counterpart of the frontend SDK's
601
+ `notifications.push()`:
602
+
603
+ ```python
604
+ from palette_sdk import PluginContext, get_plugin_context, require_permission
605
+
606
+ @router.post("/exports", dependencies=[require_permission("resources:write")])
607
+ async def start_export(ctx: PluginContext = Depends(get_plugin_context)):
608
+ ...
609
+ await ctx.notifications.push(
610
+ "Export complete",
611
+ body="Your report is ready to download.",
612
+ severity="success", # "info" | "success" | "warning" | "error"
613
+ route="/exports/123", # opened inside your app on click
614
+ data={"export_id": 123}, # arbitrary payload stored with it
615
+ )
616
+ ```
617
+
618
+ The notification shows as a live toast plus a notification-center entry and
619
+ opens/focuses your app window at the resolved route when clicked. `route`
620
+ resolves relative to your app (`/apps/{plugin_id}/exports/123`); pass an
621
+ absolute `/apps/...` route to target another app's window. Omit `route` to
622
+ open your app's main window.
623
+
624
+ By default the notification targets the user making the current request. Pass
625
+ `user_id` to notify a different member of the same organisation (useful for
626
+ approval flows); targets outside the caller's organisation are rejected:
627
+
628
+ ```python
629
+ await ctx.notifications.push(
630
+ "Approval needed",
631
+ body=f"{requester.name} requested a budget increase.",
632
+ route="/approvals",
633
+ user_id=str(approver_id),
634
+ )
635
+ ```
636
+
637
+ During `pltt dev` notifications are not delivered to a real notification
638
+ center — the simulator logs them as `[palette-notification] {...}` and returns
639
+ a stub response, so your code paths still run.
640
+
595
641
  ## 9. Config And Secrets
596
642
 
597
643
  Use config for app install settings and secrets for sensitive values.
@@ -8,7 +8,7 @@ const { watchFrontend } = require("../bundler")
8
8
  const { parseFlags, resolveEnvironment } = require("../environments")
9
9
  const { resolveDevPorts } = require("../ports")
10
10
  const { startSimulator } = require("../dev-simulator")
11
- const { loadLocalEnv } = require("../secrets")
11
+ const { loadLocalEnvDetails } = require("../secrets")
12
12
  const buildCommand = require("./build")
13
13
  const publish = require("./publish")
14
14
  const logs = require("./logs")
@@ -98,11 +98,39 @@ function lintMigrationsForDev(cwd, manifest) {
98
98
  process.exit(1)
99
99
  }
100
100
 
101
+ function formatEnvValue(value) {
102
+ if (value === undefined || value === null) return ""
103
+ const str = String(value)
104
+ if (!str) return ""
105
+ if (/[\s#"'\\]/.test(str)) return JSON.stringify(str)
106
+ return str
107
+ }
108
+
109
+ function writePlatformEnvFile(cwd, localEnv) {
110
+ const dir = path.join(cwd, ".palette")
111
+ fs.mkdirSync(dir, { recursive: true })
112
+ const envPath = path.join(dir, "platform.env")
113
+ const values = {}
114
+ for (const [key, value] of Object.entries(localEnv?.values || {})) {
115
+ values[key] = process.env[key] !== undefined ? process.env[key] : value
116
+ }
117
+ const lines = [
118
+ "# Generated by pltt dev --platform from local env files.",
119
+ "# Do not commit this file.",
120
+ "",
121
+ ]
122
+ for (const [key, value] of Object.entries(values).sort(([a], [b]) => a.localeCompare(b))) {
123
+ lines.push(`${key}=${formatEnvValue(value)}`)
124
+ }
125
+ fs.writeFileSync(envPath, `${lines.join("\n")}\n`)
126
+ return envPath
127
+ }
128
+
101
129
  async function run(args, { cwd }) {
102
130
  const { flags, rest } = parseFlags(args)
103
131
  const cloud = rest.includes("--cloud") || rest.includes("--sandbox")
104
132
  const platform = rest.includes("--platform")
105
- loadLocalEnv(cwd)
133
+ const localEnv = loadLocalEnvDetails(cwd, { environment: flags.env })
106
134
  if (cloud) {
107
135
  const json = args.includes("--json")
108
136
  const publishArgs = ["--publish-type", "preview"]
@@ -199,6 +227,9 @@ async function run(args, { cwd }) {
199
227
  }
200
228
  console.log(`[pltt] frontend: http://localhost:${frontendPort}/apps/${pluginId}`)
201
229
  console.log(`[pltt] backend: http://localhost:${backendPort}/api/v1/plugins/${pluginId}`)
230
+ if (localEnv.files.length) {
231
+ console.log(`[pltt] env files: ${localEnv.files.join(", ")}`)
232
+ }
202
233
 
203
234
  // Pre-pull so we can give a useful error if the image isn't reachable
204
235
  // (common cause: maintainer hasn't pushed it yet, or `docker login ghcr.io`
@@ -210,11 +241,13 @@ async function run(args, { cwd }) {
210
241
  process.exit(1)
211
242
  }
212
243
 
244
+ const platformEnvFile = writePlatformEnvFile(cwd, localEnv)
213
245
  const env = {
214
246
  ...process.env,
215
247
  PALETTE_DEV_IMAGE: DEFAULT_IMAGE,
216
248
  PALETTE_ACTIVE_PLUGIN: pluginId,
217
249
  PALETTE_PLUGIN_DIR: cwd,
250
+ PALETTE_PLUGIN_ENV_FILE: platformEnvFile,
218
251
  PALETTE_FRONTEND_PORT: frontendPort,
219
252
  PALETTE_BACKEND_PORT: backendPort,
220
253
  }
@@ -228,6 +228,14 @@ function scopesOf(spec) {
228
228
  return ["dev"]
229
229
  }
230
230
 
231
+ function withPluginScope(spec) {
232
+ const scopes = Array.from(new Set([...scopesOf(spec), "plugin"]))
233
+ return {
234
+ ...spec,
235
+ scope: scopes.length === 1 ? scopes[0] : scopes,
236
+ }
237
+ }
238
+
231
239
  function cloneManifest(manifest) {
232
240
  return JSON.parse(JSON.stringify(manifest))
233
241
  }
@@ -235,12 +243,6 @@ function cloneManifest(manifest) {
235
243
  function collectPluginSecrets(cwd, manifest, env, flags, log, localEnv) {
236
244
  const declared = declaredSecrets(manifest)
237
245
  const pluginSecrets = Object.entries(declared).filter(([, spec]) => spec.scope.includes("plugin"))
238
- const devRequired = Object.entries(declared).filter(([, spec]) => spec.scope.includes("dev") && spec.required)
239
- if (devRequired.length) {
240
- log(
241
- `[pltt] dev-only secrets are not uploaded: ${devRequired.map(([name]) => name).join(", ")}`,
242
- )
243
- }
244
246
 
245
247
  let fileValues = {}
246
248
  if (flags.secretsFile) {
@@ -281,7 +283,14 @@ function collectPluginSecrets(cwd, manifest, env, flags, log, localEnv) {
281
283
  }
282
284
  const explicitSpec = effectiveManifest.secrets[name]
283
285
  if (explicitSpec) {
284
- if (scopesOf(explicitSpec).includes("plugin")) values[name] = fileValues[name] ?? process.env[name] ?? localValues[name]
286
+ const value = fileValues[name] ?? process.env[name] ?? localValues[name]
287
+ if (scopesOf(explicitSpec).includes("plugin")) {
288
+ values[name] = value
289
+ } else if (canAutoUploadEnvKey(name)) {
290
+ effectiveManifest.secrets[name] = withPluginScope(explicitSpec)
291
+ values[name] = value
292
+ autoUploaded.push(name)
293
+ }
285
294
  continue
286
295
  }
287
296
  if (isReservedAutoEnvKey(name)) {
@@ -401,6 +410,7 @@ async function run(argv, { cwd }) {
401
410
  log(`[pltt] ${backend.length} bytes`)
402
411
 
403
412
  const backendSha = sha256(backend)
413
+ const frontendSha = frontend ? sha256(frontend) : null
404
414
  const api = makeApi(env)
405
415
 
406
416
  log("[pltt] requesting signed URLs")
@@ -410,6 +420,7 @@ async function run(argv, { cwd }) {
410
420
  plugin_id: manifest.id,
411
421
  version: manifest.version,
412
422
  bundle_sha256: backendSha,
423
+ ...(frontendSha ? { frontend_sha256: frontendSha } : {}),
413
424
  publish_type: publishType,
414
425
  },
415
426
  })
@@ -178,6 +178,7 @@ import pathlib
178
178
  import re
179
179
  import sys
180
180
  import uuid
181
+ from datetime import datetime, timezone
181
182
  from types import SimpleNamespace
182
183
 
183
184
  from fastapi import FastAPI, HTTPException, Request, Response
@@ -301,6 +302,47 @@ class LocalEventPublisher:
301
302
  async def publish(self, topic: str, payload=None):
302
303
  print("[palette-event]", topic, json.dumps(payload or {}, sort_keys=True))
303
304
 
305
+ class LocalNotificationsService:
306
+ """Local stand-in for the platform notification pool: logs and returns a stub."""
307
+
308
+ def __init__(self):
309
+ self._next_id = 1
310
+
311
+ def _resolve_action_route(self, route, plugin_id):
312
+ if not route:
313
+ return f"/apps/{plugin_id}" if plugin_id else None
314
+ if "://" in route:
315
+ raise ValueError("route must be an internal route starting with '/'")
316
+ if route.startswith("/apps/"):
317
+ return route
318
+ suffix = route if route.startswith("/") else "/" + route
319
+ return f"/apps/{plugin_id}{suffix}" if plugin_id else suffix
320
+
321
+ async def push(self, *, title, body=None, route=None, severity=None, data=None, user_id=None):
322
+ if not title or not str(title).strip():
323
+ raise ValueError("notification title is required")
324
+ if severity is not None and severity not in ("info", "success", "warning", "error"):
325
+ raise ValueError("severity must be one of: info, success, warning, error")
326
+ plugin_id = MANIFEST.get("id", "")
327
+ notification = {
328
+ "id": self._next_id,
329
+ "organization_id": 1,
330
+ "type": "app",
331
+ "title": str(title),
332
+ "body": body,
333
+ "data_json": json.dumps(data) if data is not None else None,
334
+ "source_app_id": plugin_id or None,
335
+ "action_route": self._resolve_action_route(route, plugin_id),
336
+ "severity": severity,
337
+ "is_read": False,
338
+ "created_at": datetime.now(timezone.utc).isoformat(),
339
+ }
340
+ self._next_id += 1
341
+ print("[palette-notification]", json.dumps(notification, sort_keys=True))
342
+ return notification
343
+
344
+ NOTIFICATIONS = LocalNotificationsService()
345
+
304
346
  spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
305
347
  module = importlib.util.module_from_spec(spec)
306
348
  assert spec and spec.loader
@@ -341,6 +383,7 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
341
383
  request.state.plugin_local_connections = DEV_CONNECTIONS
342
384
  request.state.plugin_apps = LocalAppInteropService()
343
385
  request.state.plugin_events = LocalEventPublisher()
386
+ request.state.notifications = NOTIFICATIONS
344
387
  request.state.storage = DEV_STORAGE
345
388
  if DEV_REDIS is not None:
346
389
  request.state.redis = DEV_REDIS
@@ -576,6 +619,21 @@ function normalizePaletteLanguage(language, fallback = "en") {
576
619
  return value ? value.split("-")[0] : fallback
577
620
  }
578
621
 
622
+ function normalizePaletteColorMode(mode, fallback = "light") {
623
+ return mode === "dark" ? "dark" : fallback
624
+ }
625
+
626
+ function detectPaletteColorMode() {
627
+ return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"
628
+ }
629
+
630
+ function applyPaletteColorMode(mode) {
631
+ const normalized = normalizePaletteColorMode(mode)
632
+ document.documentElement.classList.toggle("dark", normalized === "dark")
633
+ document.documentElement.style.colorScheme = normalized
634
+ window.dispatchEvent(new CustomEvent("palette:theme-change", { detail: { colorMode: normalized } }))
635
+ }
636
+
579
637
  function Toasts() {
580
638
  const [items, setItems] = React.useState([])
581
639
  React.useEffect(() => {
@@ -594,6 +652,7 @@ function Toasts() {
594
652
 
595
653
  function Shell() {
596
654
  const [language, updateLanguage] = React.useState(() => normalizePaletteLanguage(navigator.language))
655
+ const [colorMode, updateColorMode] = React.useState(() => detectPaletteColorMode())
597
656
  const platform = React.useMemo(() => ({
598
657
  ...basePlatform,
599
658
  language,
@@ -605,18 +664,34 @@ function Shell() {
605
664
  document.documentElement.lang = normalized
606
665
  window.dispatchEvent(new CustomEvent("palette:language-change", { detail: { language: normalized } }))
607
666
  },
608
- }), [language])
667
+ colorMode,
668
+ setColorMode: (nextMode) => {
669
+ updateColorMode(normalizePaletteColorMode(nextMode))
670
+ },
671
+ }), [language, colorMode])
609
672
 
610
673
  React.useEffect(() => {
611
674
  document.documentElement.lang = language
612
675
  }, [language])
613
676
 
677
+ React.useEffect(() => {
678
+ applyPaletteColorMode(colorMode)
679
+ }, [colorMode])
680
+
614
681
  React.useEffect(() => {
615
682
  const onLanguageChange = () => updateLanguage(normalizePaletteLanguage(navigator.language))
616
683
  window.addEventListener("languagechange", onLanguageChange)
617
684
  return () => window.removeEventListener("languagechange", onLanguageChange)
618
685
  }, [])
619
686
 
687
+ React.useEffect(() => {
688
+ const media = window.matchMedia?.("(prefers-color-scheme: dark)")
689
+ if (!media) return
690
+ const onColorModeChange = () => updateColorMode(detectPaletteColorMode())
691
+ media.addEventListener?.("change", onColorModeChange)
692
+ return () => media.removeEventListener?.("change", onColorModeChange)
693
+ }, [])
694
+
620
695
  return React.createElement(PluginProvider, { value: platform },
621
696
  React.createElement("main", { className: "palette-local-shell" },
622
697
  React.createElement(Plugin, { platform }),
package/lib/manifest.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require("fs")
4
4
  const path = require("path")
5
- const { SECRET_SCOPES } = require("./secrets")
5
+ const { ENV_KEY_PATTERN, SECRET_SCOPES } = require("./secrets")
6
6
 
7
7
  const MANIFEST_FILE = "palette-plugin.json"
8
8
  const SUPPORTED_MANIFEST_VERSIONS = ["1"]
@@ -32,6 +32,8 @@ const TOP_LEVEL_KEYS = new Set([
32
32
  "category",
33
33
  "tagline",
34
34
  "description",
35
+ "release_notes",
36
+ "changelog",
35
37
  "icon",
36
38
  "gradient",
37
39
  "sdk",
@@ -127,8 +129,8 @@ function validateSecrets(value, errors) {
127
129
  const allowed = new Set(["scope", "required", "label", "help", "validate"])
128
130
  for (const [name, spec] of Object.entries(value)) {
129
131
  const label = `secrets.${name}`
130
- if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) {
131
- errors.push(`${label} must be an uppercase environment-style name`)
132
+ if (!ENV_KEY_PATTERN.test(name)) {
133
+ errors.push(`${label} must be a valid environment variable name`)
132
134
  }
133
135
  if (!isObject(spec)) {
134
136
  errors.push(`${label} must be an object`)
@@ -526,6 +528,8 @@ function validateManifest(m) {
526
528
  requireString(m, "category", "manifest", errors)
527
529
  requireString(m, "tagline", "manifest", errors)
528
530
  requireString(m, "description", "manifest", errors)
531
+ requireString(m, "release_notes", "manifest", errors)
532
+ requireString(m, "changelog", "manifest", errors)
529
533
  requireString(m, "icon", "manifest", errors)
530
534
 
531
535
  if (m.gradient !== undefined) {
package/lib/secrets.js CHANGED
@@ -6,6 +6,7 @@ const path = require("path")
6
6
  const LOCAL_ENV_PATH = path.join(".palette", ".env.local")
7
7
  const EXAMPLE_ENV_PATH = path.join(".palette", ".env.example")
8
8
  const SECRET_SCOPES = new Set(["dev", "plugin", "install", "platform"])
9
+ const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
9
10
  const RESERVED_AUTO_ENV_KEYS = new Set([
10
11
  "CI",
11
12
  "HOME",
@@ -162,7 +163,7 @@ function declaredSecrets(manifest) {
162
163
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}
163
164
  const out = {}
164
165
  for (const [name, meta] of Object.entries(raw)) {
165
- if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) continue
166
+ if (!ENV_KEY_PATTERN.test(name)) continue
166
167
  const item = meta && typeof meta === "object" && !Array.isArray(meta) ? meta : {}
167
168
  out[name] = {
168
169
  ...item,
@@ -210,7 +211,7 @@ function isReservedAutoEnvKey(key) {
210
211
  }
211
212
 
212
213
  function canAutoUploadEnvKey(key) {
213
- return /^[A-Z_][A-Z0-9_]*$/.test(key) && !isPublicEnvKey(key) && !isReservedAutoEnvKey(key)
214
+ return ENV_KEY_PATTERN.test(key) && !isPublicEnvKey(key) && !isReservedAutoEnvKey(key)
214
215
  }
215
216
 
216
217
  function redactValue(value) {
@@ -222,6 +223,7 @@ function redactValue(value) {
222
223
 
223
224
  module.exports = {
224
225
  EXAMPLE_ENV_PATH,
226
+ ENV_KEY_PATTERN,
225
227
  LOCAL_ENV_PATH,
226
228
  SECRET_SCOPES,
227
229
  declaredSecrets,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.53",
3
+ "version": "0.3.55",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -9,6 +9,8 @@
9
9
  services:
10
10
  platform:
11
11
  image: ${PALETTE_DEV_IMAGE:-ghcr.io/palette-lab/platform-dev:latest}
12
+ env_file:
13
+ - ${PALETTE_PLUGIN_ENV_FILE:-/dev/null}
12
14
  ports:
13
15
  - "${PALETTE_FRONTEND_PORT:-7321}:3000"
14
16
  - "${PALETTE_BACKEND_PORT:-8732}:8000"
@@ -25,8 +27,8 @@ services:
25
27
  STORAGE_BACKEND: "local"
26
28
  LOCAL_STORAGE_DIR: "/srv/storage"
27
29
  # Disable optional features that need real credentials
28
- RAG_ENABLED: "false"
29
- OPENAI_API_KEY: ""
30
+ RAG_ENABLED: "${RAG_ENABLED:-false}"
31
+ OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
30
32
  volumes:
31
33
  - "${PALETTE_PLUGIN_DIR}:/plugins/${PALETTE_ACTIVE_PLUGIN}"
32
34
  depends_on:
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.23"
7
+ "@palettelab/sdk": "^0.1.24"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@palettelab/sdk": "^0.1.23",
5
+ "@palettelab/sdk": "^0.1.25",
6
6
  "react": "^19.0.0",
7
7
  "react-dom": "^19.0.0"
8
8
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.23",
6
+ "@palettelab/sdk": "^0.1.25",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-db-plugin",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.23", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.25", "react": "^19.0.0" }
6
6
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-external-svc",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.23", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.25", "react": "^19.0.0" }
6
6
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.23",
6
+ "@palettelab/sdk": "^0.1.25",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.23",
6
+ "@palettelab/sdk": "^0.1.25",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.23",
6
+ "@palettelab/sdk": "^0.1.25",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -2,7 +2,7 @@
2
2
  "private": true,
3
3
  "type": "module",
4
4
  "dependencies": {
5
- "@palettelab/sdk": "^0.1.23",
5
+ "@palettelab/sdk": "^0.1.25",
6
6
  "react": "^19.0.0"
7
7
  }
8
8
  }