@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 +58 -5
- package/backend-sdk/palette_sdk/__init__.py +2 -0
- package/backend-sdk/palette_sdk/notifications.py +67 -0
- package/backend-sdk/palette_sdk/plugin_context.py +4 -0
- package/backend-sdk/pyproject.toml +1 -1
- package/docs/python-backend-sdk.md +46 -0
- package/lib/commands/dev.js +35 -2
- package/lib/commands/publish.js +18 -7
- package/lib/dev-simulator.js +76 -1
- package/lib/manifest.js +7 -3
- package/lib/secrets.js +4 -2
- package/package.json +1 -1
- package/platform-dev/docker-compose.yml +4 -2
- package/template-fallback/package.json +1 -1
- package/template-fallback/templates/consumer-app/package.json +1 -1
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/next/package.json +1 -1
- package/template-fallback/templates/palette-app/package.json +1 -1
- package/template-fallback/templates/provider-app/package.json +1 -1
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
|
|
506
|
-
`language`, `fallbackLanguage`, `supportedLanguages`,
|
|
507
|
-
Generated frontend templates include
|
|
508
|
-
files wired through
|
|
509
|
-
|
|
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)),
|
|
@@ -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.
|
package/lib/commands/dev.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/lib/commands/publish.js
CHANGED
|
@@ -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
|
-
|
|
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
|
})
|
package/lib/dev-simulator.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
131
|
-
errors.push(`${label} must be
|
|
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 (
|
|
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
|
|
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
|
@@ -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:
|