@palettelab/cli 0.3.49 → 0.3.51
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 +43 -1
- package/backend-sdk/palette_sdk/__init__.py +12 -0
- package/backend-sdk/palette_sdk/apps.py +12 -0
- package/backend-sdk/palette_sdk/events.py +35 -1
- package/backend-sdk/palette_sdk/manifest.py +48 -3
- package/backend-sdk/palette_sdk/services.py +147 -0
- package/docs/python-backend-sdk.md +61 -32
- package/lib/bundler.js +72 -6
- package/lib/cli.js +8 -1
- package/lib/commands/dev.js +4 -1
- package/lib/commands/doctor.js +4 -1
- package/lib/commands/package.js +4 -1
- package/lib/commands/publish.js +4 -1
- package/lib/commands/test.js +11 -2
- package/lib/commands/version.js +20 -0
- package/lib/css-scope.js +124 -0
- package/lib/dev-simulator.js +34 -4
- package/package.json +4 -2
- package/template-fallback/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/README.md
CHANGED
|
@@ -21,6 +21,13 @@ npx @palettelab/cli <command>
|
|
|
21
21
|
pltt <command>
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
Check the installed CLI version with:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pltt version
|
|
28
|
+
pltt --version
|
|
29
|
+
```
|
|
30
|
+
|
|
24
31
|
## Quick Start: Build And Test A Palette App
|
|
25
32
|
|
|
26
33
|
Use this flow when a developer wants to create an app, run it locally, then test
|
|
@@ -667,6 +674,16 @@ pltt login --env staging --url https://sandbox.pltt.ai --token <publish-token>
|
|
|
667
674
|
pltt dev --sandbox --env staging
|
|
668
675
|
```
|
|
669
676
|
|
|
677
|
+
### `pltt version`
|
|
678
|
+
|
|
679
|
+
Show the installed `pltt` CLI version.
|
|
680
|
+
|
|
681
|
+
```bash
|
|
682
|
+
pltt version
|
|
683
|
+
pltt version --json
|
|
684
|
+
pltt --version
|
|
685
|
+
```
|
|
686
|
+
|
|
670
687
|
### `pltt doctor`
|
|
671
688
|
|
|
672
689
|
Check local tooling and common setup problems.
|
|
@@ -761,9 +778,34 @@ pltt logs --json
|
|
|
761
778
|
|
|
762
779
|
If no plugin ID is provided, the CLI uses the current `palette-plugin.json` or `.palette/last-publish.json`.
|
|
763
780
|
|
|
781
|
+
### `pltt services`
|
|
782
|
+
|
|
783
|
+
Inspect and generate OS-broker service integrations declared through
|
|
784
|
+
`provides` and `consumes` in `palette-plugin.json`.
|
|
785
|
+
|
|
786
|
+
```bash
|
|
787
|
+
pltt services list --env staging
|
|
788
|
+
pltt services add hr/v1#approvalChain.get --reason "Route approvals through HR"
|
|
789
|
+
pltt services add hr/v1#hierarchy.updated --event --optional
|
|
790
|
+
pltt services pull --env staging
|
|
791
|
+
pltt services scaffold approvalChain.get
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
Subcommands:
|
|
795
|
+
|
|
796
|
+
- `list`: fetches `/api/v1/os-broker/catalog` from the selected environment and prints services/events available to the current org. Use `--json` for the raw catalog.
|
|
797
|
+
- `add <namespace/version#name>`: appends a consumed service target to `consumes.services`. Use `--event` or `--type event` for `consumes.events`, `--optional` for non-required dependencies, and `--reason <text>` to record why the dependency is needed.
|
|
798
|
+
- `pull`: reads `consumes.services` and `consumes.events`, fetches JSON Schemas from `/api/v1/os-broker/schemas`, and generates `.palette/types/services.ts` plus `.palette/python/services.py`.
|
|
799
|
+
- `scaffold <method>`: adds a provider method to `provides.services`, creates `schemas/<method>.in.json` and `schemas/<method>.out.json`, and writes a Python handler stub under `broker/`.
|
|
800
|
+
|
|
801
|
+
Generated TypeScript clients use the SDK's default `palette` client. Generated
|
|
802
|
+
Python clients call `palette_sdk.services(ctx)`, so backend routes still pass
|
|
803
|
+
through Palette's broker permission and install checks.
|
|
804
|
+
|
|
764
805
|
## Global Flags
|
|
765
806
|
|
|
766
|
-
- `--json` emits machine-readable output for `package`, `publish`, `status`, `logs`, and `
|
|
807
|
+
- `--json` emits machine-readable output for `package`, `publish`, `status`, `logs`, `test`, and `version`.
|
|
808
|
+
- `-v, --version` prints the installed `pltt` CLI version.
|
|
767
809
|
- `--env <name>` selects a configured publish/status/logs environment.
|
|
768
810
|
- `-y, --yes` skips production publish confirmation.
|
|
769
811
|
|
|
@@ -34,7 +34,14 @@ from palette_sdk.permissions import (
|
|
|
34
34
|
is_known_permission,
|
|
35
35
|
require_permission,
|
|
36
36
|
)
|
|
37
|
+
from palette_sdk import events as events_module # also exposed as `palette_sdk.events`
|
|
37
38
|
from palette_sdk.events import Event, EventPublisher, subscribe_event
|
|
39
|
+
from palette_sdk.services import (
|
|
40
|
+
BrokerCallError,
|
|
41
|
+
ServicesClient,
|
|
42
|
+
service,
|
|
43
|
+
services,
|
|
44
|
+
)
|
|
38
45
|
from palette_sdk.config import get_config, require_config
|
|
39
46
|
from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
|
|
40
47
|
from palette_sdk.testing import route_permission_issues
|
|
@@ -77,6 +84,11 @@ __all__ = [
|
|
|
77
84
|
"Event",
|
|
78
85
|
"EventPublisher",
|
|
79
86
|
"subscribe_event",
|
|
87
|
+
"events_module",
|
|
88
|
+
"service",
|
|
89
|
+
"services",
|
|
90
|
+
"ServicesClient",
|
|
91
|
+
"BrokerCallError",
|
|
80
92
|
"get_config",
|
|
81
93
|
"require_config",
|
|
82
94
|
"sign_webhook",
|
|
@@ -64,3 +64,15 @@ class AppInteropClient:
|
|
|
64
64
|
json_body=json,
|
|
65
65
|
timeout=timeout,
|
|
66
66
|
)
|
|
67
|
+
|
|
68
|
+
async def broker_call(self, target: str, payload: dict[str, Any] | None = None) -> Any:
|
|
69
|
+
"""OS-broker RPC call. Used by the high-level `services(ctx).call(...)`."""
|
|
70
|
+
if self._adapter is None:
|
|
71
|
+
raise MissingAppServiceError("Palette OS broker is not available in this runtime")
|
|
72
|
+
return await self._adapter.broker_call(target, payload or {})
|
|
73
|
+
|
|
74
|
+
async def broker_emit(self, target: str, payload: dict[str, Any] | None = None) -> None:
|
|
75
|
+
"""Emit an event through the OS broker."""
|
|
76
|
+
if self._adapter is None:
|
|
77
|
+
raise MissingAppServiceError("Palette OS broker is not available in this runtime")
|
|
78
|
+
await self._adapter.broker_emit(target, payload or {})
|
|
@@ -44,6 +44,13 @@ _pending: list[tuple[str, Handler]] = []
|
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def subscribe_event(topic: str, handler: Handler) -> None:
|
|
47
|
+
"""Subscribe to a topic.
|
|
48
|
+
|
|
49
|
+
Accepts either a legacy bare topic (`"task.created"`) or a fully qualified
|
|
50
|
+
broker target (`"core/v1#task.created"` or `"org/v1#member.updated"`). Both
|
|
51
|
+
forms are routed to the same core bus; the broker decodes the qualified
|
|
52
|
+
form on dispatch.
|
|
53
|
+
"""
|
|
47
54
|
_pending.append((topic, handler))
|
|
48
55
|
|
|
49
56
|
|
|
@@ -54,8 +61,31 @@ def drain_pending() -> list[tuple[str, Handler]]:
|
|
|
54
61
|
return out
|
|
55
62
|
|
|
56
63
|
|
|
64
|
+
def on(topic: str) -> Callable[[Handler], Handler]:
|
|
65
|
+
"""Decorator form of `subscribe_event`.
|
|
66
|
+
|
|
67
|
+
Usage::
|
|
68
|
+
|
|
69
|
+
@events.on("org/v1#member.updated")
|
|
70
|
+
async def refresh(event):
|
|
71
|
+
...
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def decorator(fn: Handler) -> Handler:
|
|
75
|
+
subscribe_event(topic, fn)
|
|
76
|
+
return fn
|
|
77
|
+
|
|
78
|
+
return decorator
|
|
79
|
+
|
|
80
|
+
|
|
57
81
|
class EventPublisher:
|
|
58
|
-
"""Publish app-owned events declared in palette-plugin.json provides.events.
|
|
82
|
+
"""Publish app-owned events declared in palette-plugin.json provides.events.
|
|
83
|
+
|
|
84
|
+
Accepts both legacy topics (`task.created`) and qualified broker targets
|
|
85
|
+
(`org/v1#member.updated`). The adapter (injected by the platform) routes
|
|
86
|
+
the call through the broker, which checks that the caller is the declared
|
|
87
|
+
provider and validates the payload against its schema.
|
|
88
|
+
"""
|
|
59
89
|
|
|
60
90
|
def __init__(self, adapter: Any = None):
|
|
61
91
|
self._adapter = adapter
|
|
@@ -64,3 +94,7 @@ class EventPublisher:
|
|
|
64
94
|
if self._adapter is None:
|
|
65
95
|
raise RuntimeError("Palette event publisher is not available in this runtime")
|
|
66
96
|
await self._adapter.publish(topic, payload or {})
|
|
97
|
+
|
|
98
|
+
# Alias: `events.emit(...)` reads more naturally for app-owned events.
|
|
99
|
+
async def emit(self, topic: str, payload: dict[str, Any] | None = None) -> None:
|
|
100
|
+
await self.publish(topic, payload)
|
|
@@ -4,9 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Literal
|
|
7
|
+
from typing import Any, Literal
|
|
8
8
|
|
|
9
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class AgentDefinition(BaseModel):
|
|
@@ -99,6 +99,22 @@ class AppServiceRouteSpec(BaseModel):
|
|
|
99
99
|
description: str = ""
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
class AppServiceMethodSpec(BaseModel):
|
|
103
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
104
|
+
|
|
105
|
+
id: str = Field(validation_alias=AliasChoices("id", "name"))
|
|
106
|
+
scope: str | None = None
|
|
107
|
+
version: str | None = None
|
|
108
|
+
label: str | None = None
|
|
109
|
+
description: str = ""
|
|
110
|
+
input_schema: dict[str, Any] | str | None = None
|
|
111
|
+
input: dict[str, Any] | str | None = None
|
|
112
|
+
output_schema: dict[str, Any] | str | None = None
|
|
113
|
+
output: dict[str, Any] | str | None = None
|
|
114
|
+
route_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] | None = None
|
|
115
|
+
route_path: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
102
118
|
class ProvidedAppServiceSpec(BaseModel):
|
|
103
119
|
id: str
|
|
104
120
|
version: str | None = None
|
|
@@ -106,6 +122,22 @@ class ProvidedAppServiceSpec(BaseModel):
|
|
|
106
122
|
description: str = ""
|
|
107
123
|
permissions: list[str] = Field(default_factory=list)
|
|
108
124
|
routes: list[AppServiceRouteSpec] = Field(default_factory=list)
|
|
125
|
+
methods: list[AppServiceMethodSpec] = Field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ProvidedEventSpec(BaseModel):
|
|
129
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
130
|
+
|
|
131
|
+
id: str = Field(validation_alias=AliasChoices("id", "topic"))
|
|
132
|
+
version: str | None = None
|
|
133
|
+
label: str | None = None
|
|
134
|
+
description: str = ""
|
|
135
|
+
payload_schema: dict[str, Any] | str | None = None
|
|
136
|
+
schema_: dict[str, Any] | str | None = Field(
|
|
137
|
+
default=None,
|
|
138
|
+
validation_alias=AliasChoices("schema", "schema_"),
|
|
139
|
+
serialization_alias="schema",
|
|
140
|
+
)
|
|
109
141
|
|
|
110
142
|
|
|
111
143
|
class AppDependencySpec(BaseModel):
|
|
@@ -123,9 +155,16 @@ class ServiceDependencySpec(BaseModel):
|
|
|
123
155
|
provider: str | None = None
|
|
124
156
|
|
|
125
157
|
|
|
158
|
+
class ConsumedBrokerTargetSpec(BaseModel):
|
|
159
|
+
target: str
|
|
160
|
+
required: bool = True
|
|
161
|
+
reason: str = ""
|
|
162
|
+
|
|
163
|
+
|
|
126
164
|
class ProvidesSpec(BaseModel):
|
|
165
|
+
namespace: str | None = None
|
|
127
166
|
services: list[ProvidedAppServiceSpec] = Field(default_factory=list)
|
|
128
|
-
events: list[str] = Field(default_factory=list)
|
|
167
|
+
events: list[str | ProvidedEventSpec] = Field(default_factory=list)
|
|
129
168
|
|
|
130
169
|
|
|
131
170
|
class RequiresSpec(BaseModel):
|
|
@@ -134,6 +173,11 @@ class RequiresSpec(BaseModel):
|
|
|
134
173
|
events: list[str] = Field(default_factory=list)
|
|
135
174
|
|
|
136
175
|
|
|
176
|
+
class ConsumesSpec(BaseModel):
|
|
177
|
+
services: list[str | ConsumedBrokerTargetSpec] = Field(default_factory=list)
|
|
178
|
+
events: list[str | ConsumedBrokerTargetSpec] = Field(default_factory=list)
|
|
179
|
+
|
|
180
|
+
|
|
137
181
|
class PlatformServiceSpec(BaseModel):
|
|
138
182
|
required: bool = False
|
|
139
183
|
billing: Literal["org_wallet", "plugin_owner", "platform"] | None = None
|
|
@@ -167,6 +211,7 @@ class PluginManifest(BaseModel):
|
|
|
167
211
|
connections: list[ConnectionSpec] = Field(default_factory=list)
|
|
168
212
|
provides: ProvidesSpec | None = None
|
|
169
213
|
requires: RequiresSpec | None = None
|
|
214
|
+
consumes: ConsumesSpec | None = None
|
|
170
215
|
platform_services: list[Literal["llm", "redis", "storage", "vector"]] | dict[str, PlatformServiceSpec] = Field(default_factory=list)
|
|
171
216
|
rating: float = 0.0
|
|
172
217
|
reviews: int = 0
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""OS-broker service helpers - provider + consumer side.
|
|
2
|
+
|
|
3
|
+
Provider example::
|
|
4
|
+
|
|
5
|
+
from palette_sdk import service, PluginContext
|
|
6
|
+
|
|
7
|
+
@service("members.list", scope="org/members.read")
|
|
8
|
+
async def list_members(ctx: PluginContext, payload: dict) -> dict:
|
|
9
|
+
return {"items": [...]}
|
|
10
|
+
|
|
11
|
+
Consumer example::
|
|
12
|
+
|
|
13
|
+
from palette_sdk import services
|
|
14
|
+
|
|
15
|
+
members = await services(ctx).call("org/v1#members.list", {"team_id": 7})
|
|
16
|
+
|
|
17
|
+
The decorator captures handlers at import time; the platform's plugin loader
|
|
18
|
+
binds them into the broker registry when the plugin is mounted, prefixing the
|
|
19
|
+
method names with the plugin's declared namespace/version.
|
|
20
|
+
|
|
21
|
+
The consumer accessor goes through `ctx.apps.broker_call()`, which the
|
|
22
|
+
platform injects with the same signed-internal-call transport used elsewhere.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
ServiceHandler = Callable[[Any, dict[str, Any]], Awaitable[Any]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ServiceDecl:
|
|
36
|
+
name: str
|
|
37
|
+
handler: ServiceHandler
|
|
38
|
+
scope: str | None = None
|
|
39
|
+
label: str | None = None
|
|
40
|
+
description: str | None = None
|
|
41
|
+
input_schema: dict | None = None
|
|
42
|
+
output_schema: dict | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_pending: list[ServiceDecl] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def service(
|
|
49
|
+
name: str,
|
|
50
|
+
*,
|
|
51
|
+
scope: str | None = None,
|
|
52
|
+
label: str | None = None,
|
|
53
|
+
description: str | None = None,
|
|
54
|
+
input_schema: dict | None = None,
|
|
55
|
+
output_schema: dict | None = None,
|
|
56
|
+
) -> Callable[[ServiceHandler], ServiceHandler]:
|
|
57
|
+
"""Mark an async function as a broker-callable service method."""
|
|
58
|
+
|
|
59
|
+
def decorator(fn: ServiceHandler) -> ServiceHandler:
|
|
60
|
+
_pending.append(
|
|
61
|
+
ServiceDecl(
|
|
62
|
+
name=name,
|
|
63
|
+
handler=fn,
|
|
64
|
+
scope=scope,
|
|
65
|
+
label=label,
|
|
66
|
+
description=description,
|
|
67
|
+
input_schema=input_schema,
|
|
68
|
+
output_schema=output_schema,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return fn
|
|
72
|
+
|
|
73
|
+
return decorator
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def drain_pending_services() -> list[ServiceDecl]:
|
|
77
|
+
"""Platform-only. Drain and return services registered since last drain."""
|
|
78
|
+
out = list(_pending)
|
|
79
|
+
_pending.clear()
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BrokerCallError(RuntimeError):
|
|
84
|
+
"""Raised when the broker rejects or fails a service call."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ServicesClient:
|
|
88
|
+
"""Returned by `services(ctx)`. Use `.call(target, payload)` or sugar `.proxy(...)`."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, adapter: Any | None):
|
|
91
|
+
self._adapter = adapter
|
|
92
|
+
|
|
93
|
+
async def call(self, target: str, payload: dict[str, Any] | None = None) -> Any:
|
|
94
|
+
if self._adapter is None:
|
|
95
|
+
raise BrokerCallError(
|
|
96
|
+
"Palette OS broker is not available in this runtime. "
|
|
97
|
+
"This call must run inside a plugin request handler."
|
|
98
|
+
)
|
|
99
|
+
return await self._adapter.broker_call(target, payload or {})
|
|
100
|
+
|
|
101
|
+
async def emit(self, target: str, payload: dict[str, Any] | None = None) -> None:
|
|
102
|
+
if self._adapter is None:
|
|
103
|
+
raise BrokerCallError("Palette OS broker is not available in this runtime.")
|
|
104
|
+
await self._adapter.broker_emit(target, payload or {})
|
|
105
|
+
|
|
106
|
+
def proxy(self, namespace_version: str) -> "_Proxy":
|
|
107
|
+
return _Proxy(self, namespace_version)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Proxy:
|
|
111
|
+
"""Dotted-attribute syntax to broker target string."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, client: ServicesClient, namespace_version: str, segments: tuple[str, ...] = ()):
|
|
114
|
+
if "/" not in namespace_version:
|
|
115
|
+
raise ValueError("namespace_version must look like 'org/v1'")
|
|
116
|
+
self._client = client
|
|
117
|
+
self._ns = namespace_version
|
|
118
|
+
self._segments = segments
|
|
119
|
+
|
|
120
|
+
def __getattr__(self, item: str) -> "_Proxy":
|
|
121
|
+
return _Proxy(self._client, self._ns, self._segments + (item,))
|
|
122
|
+
|
|
123
|
+
async def __call__(self, payload: dict[str, Any] | None = None) -> Any:
|
|
124
|
+
if not self._segments:
|
|
125
|
+
raise BrokerCallError("missing method name on proxy call")
|
|
126
|
+
method = ".".join(self._segments)
|
|
127
|
+
return await self._client.call(f"{self._ns}#{method}", payload)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def services(ctx_or_adapter: Any) -> ServicesClient:
|
|
131
|
+
"""Resolve a `ServicesClient` from either a `PluginContext` or an adapter."""
|
|
132
|
+
if ctx_or_adapter is None:
|
|
133
|
+
return ServicesClient(None)
|
|
134
|
+
apps = getattr(ctx_or_adapter, "apps", None)
|
|
135
|
+
if apps is not None and hasattr(apps, "broker_call"):
|
|
136
|
+
return ServicesClient(apps)
|
|
137
|
+
return ServicesClient(ctx_or_adapter)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = [
|
|
141
|
+
"BrokerCallError",
|
|
142
|
+
"ServiceDecl",
|
|
143
|
+
"ServicesClient",
|
|
144
|
+
"drain_pending_services",
|
|
145
|
+
"service",
|
|
146
|
+
"services",
|
|
147
|
+
]
|
|
@@ -114,7 +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.apps` | Governed app-to-app calls through declared `
|
|
117
|
+
| `ctx.apps` / `services(ctx)` | Governed app-to-app calls through declared `consumes` contracts |
|
|
118
118
|
| `ctx.events` | Publish event topics declared in `provides.events` |
|
|
119
119
|
| `ctx.redis` | Plugin/org-scoped Redis-style service when `platform_services` includes `redis` |
|
|
120
120
|
| `ctx.vector` | Plugin/org-scoped vector service when `platform_services` includes `vector` |
|
|
@@ -141,13 +141,15 @@ These are the public Python helpers exported by `palette_sdk`.
|
|
|
141
141
|
| `KNOWN_PERMISSIONS`, `is_known_permission(...)` | Permission vocabulary checks for manifests/tools |
|
|
142
142
|
| `DataRoomsClient`, `ctx.data_rooms` | Backend Data Room room/folder/file helpers |
|
|
143
143
|
| `OrganizationMembersClient`, `ctx.members` | Current-organization member lookup, invite, and role helpers |
|
|
144
|
-
| `
|
|
144
|
+
| `PluginConnectionsClient`, `ConnectionStatus`, `MissingConnectionError`, `ctx.connections` | Third-party connection status and token helpers |
|
|
145
|
+
| `AppInteropClient`, `AppServiceClient`, `MissingAppServiceError`, `ctx.apps` | Call required apps/services without direct database access |
|
|
146
|
+
| `service(...)`, `services(ctx)`, `ServicesClient`, `BrokerCallError` | Provide and consume OS-broker service methods/events |
|
|
145
147
|
| `OrgRepository`, `ctx.repo(Model)` | Org-safe convenience CRUD for app-owned models |
|
|
146
148
|
| `PluginBase`, `OrgScopedTable` | SQLAlchemy declarative bases for plugin-owned tables |
|
|
147
149
|
| `ensure_org_rls(op, table)` | Alembic helper that enables org row-level security |
|
|
148
150
|
| `plugin_safe_id(...)`, `plugin_schema(...)`, `plugin_table_prefix(...)` | Manifest id to database-safe naming helpers |
|
|
149
151
|
| `get_config(ctx, key)`, `require_config(ctx, key)` | Functional form of config reads when not using `ctx.config_value(...)` |
|
|
150
|
-
| `LocalRedisService`, `LocalVectorService` | Local `pltt dev` service emulators and test fakes |
|
|
152
|
+
| `LocalRedisService`, `LocalVectorService`, `LocalStorageService` | Local `pltt dev` service emulators and test fakes |
|
|
151
153
|
| `PlatformServiceUnavailable`, `UnavailablePlatformService` | Clear errors when an undeclared platform service is used |
|
|
152
154
|
| `LifecycleHooks` | Install/update/enable/disable/uninstall callbacks |
|
|
153
155
|
| `Event`, `EventPublisher`, `subscribe_event(...)` | In-process event subscriptions and declared app event publishing |
|
|
@@ -690,57 +692,84 @@ token = await ctx.connections.access_token("google_calendar")
|
|
|
690
692
|
|
|
691
693
|
## 11. App-To-App Services
|
|
692
694
|
|
|
693
|
-
Apps can expose governed services and
|
|
694
|
-
apps
|
|
695
|
+
Apps can expose governed broker services and events, then consume them from
|
|
696
|
+
other installed apps without knowing another app's URL.
|
|
697
|
+
|
|
698
|
+
Provider apps declare a namespace and callable methods with `provides`:
|
|
695
699
|
|
|
696
700
|
```json
|
|
697
701
|
{
|
|
698
702
|
"provides": {
|
|
703
|
+
"namespace": "hr",
|
|
699
704
|
"services": [
|
|
700
705
|
{
|
|
701
|
-
"id": "
|
|
702
|
-
"
|
|
703
|
-
|
|
704
|
-
{ "method": "GET", "path": "/hierarchy/approval-chain/{user_id}" }
|
|
706
|
+
"id": "hr.directory",
|
|
707
|
+
"methods": [
|
|
708
|
+
{ "name": "approvalChain.get", "input_schema": "schemas/approval-chain.input.json" }
|
|
705
709
|
]
|
|
706
710
|
}
|
|
707
711
|
],
|
|
708
|
-
"events": ["hierarchy.updated"]
|
|
712
|
+
"events": [{ "topic": "hierarchy.updated" }]
|
|
709
713
|
}
|
|
710
714
|
}
|
|
711
715
|
```
|
|
712
716
|
|
|
713
|
-
|
|
717
|
+
Expose the handler with `@service`:
|
|
718
|
+
|
|
719
|
+
```python
|
|
720
|
+
from palette_sdk import PluginContext, service
|
|
721
|
+
|
|
722
|
+
@service("approvalChain.get")
|
|
723
|
+
async def approval_chain(ctx: PluginContext, payload: dict) -> dict:
|
|
724
|
+
return {"approvers": [{"user_id": ctx.user_id, "step": 1}]}
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
Consumers declare qualified targets with `consumes`:
|
|
714
728
|
|
|
715
729
|
```json
|
|
716
730
|
{
|
|
717
|
-
"
|
|
731
|
+
"consumes": {
|
|
718
732
|
"services": [
|
|
719
|
-
{
|
|
733
|
+
{
|
|
734
|
+
"target": "hr/v1#approvalChain.get",
|
|
735
|
+
"required": true,
|
|
736
|
+
"reason": "Route leave approvals through the HR hierarchy"
|
|
737
|
+
}
|
|
738
|
+
],
|
|
739
|
+
"events": [
|
|
740
|
+
{ "target": "hr/v1#hierarchy.updated", "required": false }
|
|
720
741
|
]
|
|
721
742
|
}
|
|
722
743
|
}
|
|
723
744
|
```
|
|
724
745
|
|
|
725
|
-
|
|
726
|
-
the same app emits `leave.requested`, declare that topic under its own
|
|
727
|
-
`provides.events` first:
|
|
746
|
+
The CLI can update the manifest and generate typed clients for those targets:
|
|
728
747
|
|
|
729
|
-
```
|
|
730
|
-
|
|
731
|
-
|
|
748
|
+
```bash
|
|
749
|
+
pltt services add hr/v1#approvalChain.get --reason "Route leave approvals through HR"
|
|
750
|
+
pltt services add hr/v1#hierarchy.updated --event --optional
|
|
751
|
+
pltt services pull --env staging
|
|
732
752
|
```
|
|
733
753
|
|
|
734
|
-
|
|
754
|
+
`pltt services pull` writes `.palette/types/services.ts` for frontend code and
|
|
755
|
+
`.palette/python/services.py` for backend code from the schemas returned by the
|
|
756
|
+
selected Palette environment.
|
|
735
757
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
758
|
+
Then call through the OS broker; never read another app's database directly.
|
|
759
|
+
If the same app emits `leave.requested`, declare that topic under its own
|
|
760
|
+
`provides.events` first and emit with `ctx.events.emit`:
|
|
761
|
+
|
|
762
|
+
```python
|
|
763
|
+
from palette_sdk import services
|
|
764
|
+
|
|
765
|
+
chain = await services(ctx).call("hr/v1#approvalChain.get", {"user_id": ctx.user_id})
|
|
766
|
+
await ctx.events.emit("leave/v1#leave.requested", {"approval_chain": chain})
|
|
742
767
|
```
|
|
743
768
|
|
|
769
|
+
At install time, Palette checks required `consumes` targets and can show the
|
|
770
|
+
provider apps that must also be installed. The org-level grant is saved on the
|
|
771
|
+
install and the broker checks it on every call, emit, or event stream.
|
|
772
|
+
|
|
744
773
|
App storage is separate from Data Rooms. Use `ctx.storage` and `palette.storage` for app-owned files that go directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` or `palette.dataRooms` only when the file should be visible and governed as a Data Room document.
|
|
745
774
|
|
|
746
775
|
Palette scopes storage the same way. Files written through `ctx.storage` or the
|
|
@@ -887,7 +916,7 @@ point = ctx.vector.scoped_point(
|
|
|
887
916
|
await client.upsert(collection_name=collection, points=[point])
|
|
888
917
|
```
|
|
889
918
|
|
|
890
|
-
##
|
|
919
|
+
## 12. Lifecycle Hooks
|
|
891
920
|
|
|
892
921
|
Lifecycle hooks let an app seed defaults or clean up app-owned data when the
|
|
893
922
|
platform installs, updates, enables, disables, or uninstalls the app.
|
|
@@ -909,7 +938,7 @@ async def on_enable(ctx: PluginContext):
|
|
|
909
938
|
ctx.logger.info("finance tools enabled for org %s", ctx.organization_id)
|
|
910
939
|
```
|
|
911
940
|
|
|
912
|
-
##
|
|
941
|
+
## 13. Calling Backend APIs From Frontend
|
|
913
942
|
|
|
914
943
|
Frontend code should call backend routes through the platform API helper, not by
|
|
915
944
|
hardcoding backend origins.
|
|
@@ -926,7 +955,7 @@ async function loadInvoices() {
|
|
|
926
955
|
During `pltt dev`, the simulator routes this to the local backend. In hosted
|
|
927
956
|
sandbox or OS runtime, it goes through the real platform shell and auth context.
|
|
928
957
|
|
|
929
|
-
##
|
|
958
|
+
## 14. Local Development
|
|
930
959
|
|
|
931
960
|
Create and run an app:
|
|
932
961
|
|
|
@@ -948,7 +977,7 @@ The CLI checks manifest shape, SDK compatibility, frontend bundling, backend
|
|
|
948
977
|
imports, backend route permission gates, declared permissions, migration safety,
|
|
949
978
|
package dependency policy, and backend package size.
|
|
950
979
|
|
|
951
|
-
##
|
|
980
|
+
## 15. Test In Real Palette OS Without Docker
|
|
952
981
|
|
|
953
982
|
Configure the hosted sandbox once:
|
|
954
983
|
|
|
@@ -978,7 +1007,7 @@ APPSTORE_AUTO_APPROVE_SANDBOX_PREVIEWS=true
|
|
|
978
1007
|
|
|
979
1008
|
That lets passing preview publishes become active without manual approval.
|
|
980
1009
|
|
|
981
|
-
##
|
|
1010
|
+
## 16. Common Mistakes
|
|
982
1011
|
|
|
983
1012
|
- Do not import `@palettelab/sdk` from Python. That package is for frontend
|
|
984
1013
|
JavaScript/React code only.
|
|
@@ -993,7 +1022,7 @@ That lets passing preview publishes become active without manual approval.
|
|
|
993
1022
|
- Use hosted sandbox when you need real Data Rooms, login, organization,
|
|
994
1023
|
install, logs, and OS behavior without Docker.
|
|
995
1024
|
|
|
996
|
-
##
|
|
1025
|
+
## 17. Complete Minimal Example
|
|
997
1026
|
|
|
998
1027
|
`backend/api/main.py`:
|
|
999
1028
|
|
package/lib/bundler.js
CHANGED
|
@@ -4,6 +4,7 @@ const path = require("path")
|
|
|
4
4
|
const fs = require("fs")
|
|
5
5
|
const os = require("os")
|
|
6
6
|
const { generatePaletteAppEntry } = require("./app-router")
|
|
7
|
+
const { appendCssExport, scopePluginCss } = require("./css-scope")
|
|
7
8
|
|
|
8
9
|
const NEXT_CONFIG_NAMES = [
|
|
9
10
|
"frontend/next.config.ts",
|
|
@@ -246,6 +247,25 @@ function currentSearchParams() {
|
|
|
246
247
|
return new URLSearchParams(window.location.search)
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
function preserveCurrentPreviewParams(path, pluginId) {
|
|
251
|
+
if (typeof window === "undefined" || !pluginId || /^https?:\\/\\//i.test(path)) return path
|
|
252
|
+
|
|
253
|
+
const currentParams = new URLSearchParams(window.location.search)
|
|
254
|
+
const previewPublishId = currentParams.get("preview_publish_id")
|
|
255
|
+
const previewToken = currentParams.get("preview_token")
|
|
256
|
+
if (!previewPublishId || !previewToken) return path
|
|
257
|
+
|
|
258
|
+
const [beforeHash, hash = ""] = path.split("#", 2)
|
|
259
|
+
const [pathname, query = ""] = beforeHash.split("?", 2)
|
|
260
|
+
if (pathname !== "/apps/" + pluginId && !pathname.startsWith("/apps/" + pluginId + "/")) return path
|
|
261
|
+
|
|
262
|
+
const params = new URLSearchParams(query)
|
|
263
|
+
params.set("preview_publish_id", previewPublishId)
|
|
264
|
+
params.set("preview_token", previewToken)
|
|
265
|
+
const serialized = params.toString()
|
|
266
|
+
return pathname + (serialized ? "?" + serialized : "") + (hash ? "#" + hash : "")
|
|
267
|
+
}
|
|
268
|
+
|
|
249
269
|
function renderRoute(route) {
|
|
250
270
|
const page = createElement(route.page)
|
|
251
271
|
return (route.layouts || []).reduceRight((children, Layout) => createElement(Layout, null, children), page)
|
|
@@ -283,8 +303,9 @@ export function PaletteAppRouter({ routes, notFound: NotFound }) {
|
|
|
283
303
|
const osPath = inPalettePath && platform.pluginId
|
|
284
304
|
? "/apps/" + platform.pluginId + (pathname === "/" ? "" : pathname) + (queryPart ? "?" + queryPart : "")
|
|
285
305
|
: next
|
|
286
|
-
|
|
287
|
-
|
|
306
|
+
const preservedOsPath = preserveCurrentPreviewParams(osPath, platform.pluginId)
|
|
307
|
+
if (replace && typeof window !== "undefined") window.history.replaceState(null, "", preservedOsPath)
|
|
308
|
+
else platform.navigate(preservedOsPath)
|
|
288
309
|
}, [platform])
|
|
289
310
|
const state = useMemo(() => ({
|
|
290
311
|
pathname: location.pathname,
|
|
@@ -382,6 +403,43 @@ function mergePlugins(...pluginGroups) {
|
|
|
382
403
|
return pluginGroups.flat().filter(Boolean)
|
|
383
404
|
}
|
|
384
405
|
|
|
406
|
+
function pluginCssScopeId(frontend = {}) {
|
|
407
|
+
return frontend.pluginId || frontend.id || "plugin"
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function cssOutputForJs(outfile) {
|
|
411
|
+
const ext = path.extname(outfile)
|
|
412
|
+
return `${outfile.slice(0, outfile.length - ext.length)}.css`
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function outputPathFromMetafile(result, suffix) {
|
|
416
|
+
const outputs = Object.keys(result.metafile?.outputs || {})
|
|
417
|
+
return outputs.find((output) => output.endsWith(suffix)) || null
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function writeScopedCssExport({ jsPath, cssPath, pluginId, deleteCss = true }) {
|
|
421
|
+
if (!cssPath || !fs.existsSync(cssPath)) return
|
|
422
|
+
const css = fs.readFileSync(cssPath, "utf8")
|
|
423
|
+
const scopedCss = scopePluginCss(css, pluginId)
|
|
424
|
+
if (scopedCss.trim()) {
|
|
425
|
+
const js = fs.readFileSync(jsPath, "utf8")
|
|
426
|
+
fs.writeFileSync(jsPath, appendCssExport(js, scopedCss))
|
|
427
|
+
}
|
|
428
|
+
if (deleteCss) fs.rmSync(cssPath, { force: true })
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function bundleOutputsToFrontendBuffer(result, pluginId) {
|
|
432
|
+
const outputs = result.outputFiles || []
|
|
433
|
+
const jsOutput = outputs.find((file) => /\.(mjs|js)$/.test(file.path))
|
|
434
|
+
if (!jsOutput) throw new Error("esbuild produced no JavaScript output")
|
|
435
|
+
const css = outputs
|
|
436
|
+
.filter((file) => file.path.endsWith(".css"))
|
|
437
|
+
.map((file) => file.text)
|
|
438
|
+
.join("\n")
|
|
439
|
+
const js = jsOutput.text
|
|
440
|
+
return Buffer.from(appendCssExport(js, scopePluginCss(css, pluginId)))
|
|
441
|
+
}
|
|
442
|
+
|
|
385
443
|
/**
|
|
386
444
|
* Bundle the plugin's frontend entry into a single ESM file.
|
|
387
445
|
*
|
|
@@ -393,8 +451,8 @@ function mergePlugins(...pluginGroups) {
|
|
|
393
451
|
async function bundleFrontend(pluginDir, entry, frontend = {}) {
|
|
394
452
|
pluginDir = path.resolve(pluginDir)
|
|
395
453
|
const esbuild = loadEsbuild()
|
|
396
|
-
const tmp =
|
|
397
|
-
const bundleEntry =
|
|
454
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "palette-frontend-"))
|
|
455
|
+
const bundleEntry = isPaletteApp(frontend)
|
|
398
456
|
? generatePaletteAppEntry(pluginDir, entry || "./frontend/app", path.join(tmp, "entry.tsx"))
|
|
399
457
|
: entry
|
|
400
458
|
const absEntry = path.resolve(pluginDir, bundleEntry)
|
|
@@ -407,6 +465,7 @@ async function bundleFrontend(pluginDir, entry, frontend = {}) {
|
|
|
407
465
|
const result = await esbuild.build({
|
|
408
466
|
entryPoints: [absEntry],
|
|
409
467
|
bundle: true,
|
|
468
|
+
outfile: path.join(tmp, "frontend.mjs"),
|
|
410
469
|
format: "esm",
|
|
411
470
|
platform: "browser",
|
|
412
471
|
target: ["es2022"],
|
|
@@ -432,9 +491,9 @@ async function bundleFrontend(pluginDir, entry, frontend = {}) {
|
|
|
432
491
|
if (!result.outputFiles || result.outputFiles.length === 0) {
|
|
433
492
|
throw new Error("esbuild produced no output")
|
|
434
493
|
}
|
|
435
|
-
return
|
|
494
|
+
return bundleOutputsToFrontendBuffer(result, pluginCssScopeId(frontend))
|
|
436
495
|
} finally {
|
|
437
|
-
|
|
496
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
438
497
|
}
|
|
439
498
|
}
|
|
440
499
|
|
|
@@ -473,6 +532,7 @@ async function watchFrontend(pluginDir, entry, outfile, frontend = {}) {
|
|
|
473
532
|
],
|
|
474
533
|
minify: false,
|
|
475
534
|
sourcemap: "inline",
|
|
535
|
+
metafile: true,
|
|
476
536
|
logLevel: "silent",
|
|
477
537
|
absWorkingDir: pluginDir,
|
|
478
538
|
plugins: [
|
|
@@ -488,6 +548,12 @@ async function watchFrontend(pluginDir, entry, outfile, frontend = {}) {
|
|
|
488
548
|
}
|
|
489
549
|
return
|
|
490
550
|
}
|
|
551
|
+
const cssPath = outputPathFromMetafile(result, ".css") || cssOutputForJs(outfile)
|
|
552
|
+
writeScopedCssExport({
|
|
553
|
+
jsPath: outfile,
|
|
554
|
+
cssPath,
|
|
555
|
+
pluginId: pluginCssScopeId(frontend),
|
|
556
|
+
})
|
|
491
557
|
const size = fs.existsSync(outfile) ? fs.statSync(outfile).size : 0
|
|
492
558
|
console.log(`[pltt] frontend bundle ready (${size} bytes)`)
|
|
493
559
|
})
|
package/lib/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ const status = require("./commands/status")
|
|
|
12
12
|
const logs = require("./commands/logs")
|
|
13
13
|
const secrets = require("./commands/secrets")
|
|
14
14
|
const services = require("./commands/services")
|
|
15
|
+
const version = require("./commands/version")
|
|
15
16
|
|
|
16
17
|
const COMMANDS = {
|
|
17
18
|
init: { run: init, help: "Scaffold a new plugin directory from the template" },
|
|
@@ -55,6 +56,7 @@ const COMMANDS = {
|
|
|
55
56
|
run: services,
|
|
56
57
|
help: "Inspect / pull / scaffold OS-broker services (list, pull, scaffold)",
|
|
57
58
|
},
|
|
59
|
+
version: { run: version, help: "Show the installed pltt CLI version" },
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
function printHelp() {
|
|
@@ -65,7 +67,8 @@ function printHelp() {
|
|
|
65
67
|
console.log(` ${name.padEnd(8)} ${help}`)
|
|
66
68
|
}
|
|
67
69
|
console.log("\nGlobal flags:")
|
|
68
|
-
console.log(" --json Emit machine-readable JSON output (status, logs, package, publish, test)")
|
|
70
|
+
console.log(" --json Emit machine-readable JSON output (status, logs, package, publish, test, version)")
|
|
71
|
+
console.log(" -v, --version Show the installed pltt CLI version")
|
|
69
72
|
console.log("\nPublish flags:")
|
|
70
73
|
console.log(" --env <name> Target environment from ~/.palette/config.json (default: local)")
|
|
71
74
|
console.log(" -y, --yes Skip interactive confirmation for production pushes")
|
|
@@ -108,6 +111,10 @@ function printHelp() {
|
|
|
108
111
|
|
|
109
112
|
async function run(argv) {
|
|
110
113
|
const cmd = argv[0]
|
|
114
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
115
|
+
version(argv.slice(1))
|
|
116
|
+
return
|
|
117
|
+
}
|
|
111
118
|
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
112
119
|
printHelp()
|
|
113
120
|
return
|
package/lib/commands/dev.js
CHANGED
|
@@ -172,7 +172,10 @@ async function run(args, { cwd }) {
|
|
|
172
172
|
if (manifest.frontend?.entry) {
|
|
173
173
|
console.log(`[pltt] bundling frontend ${frontendEntry} → .palette/dist/frontend.mjs`)
|
|
174
174
|
try {
|
|
175
|
-
frontendWatcher = await watchFrontend(cwd, frontendEntry, frontendBundle,
|
|
175
|
+
frontendWatcher = await watchFrontend(cwd, frontendEntry, frontendBundle, {
|
|
176
|
+
...manifest.frontend,
|
|
177
|
+
pluginId,
|
|
178
|
+
})
|
|
176
179
|
} catch (err) {
|
|
177
180
|
console.error(
|
|
178
181
|
`[pltt] could not start frontend bundler: ${
|
package/lib/commands/doctor.js
CHANGED
|
@@ -143,7 +143,10 @@ async function run(args, { cwd }) {
|
|
|
143
143
|
|
|
144
144
|
if (manifest.frontend?.entry) {
|
|
145
145
|
try {
|
|
146
|
-
const bundle = await bundleFrontend(cwd, manifest.frontend.entry,
|
|
146
|
+
const bundle = await bundleFrontend(cwd, manifest.frontend.entry, {
|
|
147
|
+
...manifest.frontend,
|
|
148
|
+
pluginId: manifest.id,
|
|
149
|
+
})
|
|
147
150
|
ok(`frontend bundles successfully (${bundle.length} bytes)`)
|
|
148
151
|
} catch (err) {
|
|
149
152
|
failures += fail(
|
package/lib/commands/package.js
CHANGED
|
@@ -43,7 +43,10 @@ async function run(argv, { cwd }) {
|
|
|
43
43
|
fs.mkdirSync(distDir, { recursive: true })
|
|
44
44
|
|
|
45
45
|
const frontend = manifest.frontend
|
|
46
|
-
? await bundleFrontend(cwd, manifest.frontend.entry || "./frontend/src/index.tsx",
|
|
46
|
+
? await bundleFrontend(cwd, manifest.frontend.entry || "./frontend/src/index.tsx", {
|
|
47
|
+
...manifest.frontend,
|
|
48
|
+
pluginId: manifest.id,
|
|
49
|
+
})
|
|
47
50
|
: null
|
|
48
51
|
const backend = manifest.backend ? await bundleBackend(cwd) : null
|
|
49
52
|
|
package/lib/commands/publish.js
CHANGED
|
@@ -387,7 +387,10 @@ async function run(argv, { cwd }) {
|
|
|
387
387
|
let frontend = null
|
|
388
388
|
if (manifest.frontend?.entry) {
|
|
389
389
|
log("[pltt] bundling frontend")
|
|
390
|
-
frontend = await bundleFrontend(cwd, manifest.frontend.entry,
|
|
390
|
+
frontend = await bundleFrontend(cwd, manifest.frontend.entry, {
|
|
391
|
+
...manifest.frontend,
|
|
392
|
+
pluginId: manifest.id,
|
|
393
|
+
})
|
|
391
394
|
log(`[pltt] ${frontend.length} bytes`)
|
|
392
395
|
} else {
|
|
393
396
|
log("[pltt] no frontend declared")
|
package/lib/commands/test.js
CHANGED
|
@@ -758,14 +758,20 @@ async function run(args, { cwd }) {
|
|
|
758
758
|
}
|
|
759
759
|
if ((manifest.permissions || []).length) out.ok("declared permissions are known")
|
|
760
760
|
|
|
761
|
-
if (manifest.provides || manifest.requires) {
|
|
761
|
+
if (manifest.provides || manifest.requires || manifest.consumes) {
|
|
762
762
|
const providedServices = manifest.provides?.services?.map((item) => item.id).filter(Boolean) || []
|
|
763
763
|
const requiredServices = manifest.requires?.services?.map((item) => item.id).filter(Boolean) || []
|
|
764
764
|
const requiredApps = manifest.requires?.apps?.map((item) => item.id).filter(Boolean) || []
|
|
765
|
+
const consumedServices =
|
|
766
|
+
manifest.consumes?.services?.map((item) => (typeof item === "string" ? item : item.target)).filter(Boolean) || []
|
|
767
|
+
const consumedEvents =
|
|
768
|
+
manifest.consumes?.events?.map((item) => (typeof item === "string" ? item : item.target)).filter(Boolean) || []
|
|
765
769
|
out.ok("app-to-app contracts are valid", {
|
|
766
770
|
provides_services: providedServices,
|
|
767
771
|
requires_services: requiredServices,
|
|
768
772
|
requires_apps: requiredApps,
|
|
773
|
+
consumes_services: consumedServices,
|
|
774
|
+
consumes_events: consumedEvents,
|
|
769
775
|
})
|
|
770
776
|
}
|
|
771
777
|
|
|
@@ -787,7 +793,10 @@ async function run(args, { cwd }) {
|
|
|
787
793
|
|
|
788
794
|
if (manifest.frontend?.entry) {
|
|
789
795
|
try {
|
|
790
|
-
const frontend = await bundleFrontend(cwd, manifest.frontend.entry,
|
|
796
|
+
const frontend = await bundleFrontend(cwd, manifest.frontend.entry, {
|
|
797
|
+
...manifest.frontend,
|
|
798
|
+
pluginId: manifest.id,
|
|
799
|
+
})
|
|
791
800
|
out.ok(`frontend bundles successfully (${frontend.length} bytes)`, { bytes: frontend.length })
|
|
792
801
|
failures += checkBundleSize("frontend", frontend.length, out)
|
|
793
802
|
failures += checkFrontendSecretLeaks(cwd, frontend, manifest, out)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const pkg = require("../../package.json")
|
|
4
|
+
|
|
5
|
+
function version(args = []) {
|
|
6
|
+
if (args.includes("--json")) {
|
|
7
|
+
console.log(
|
|
8
|
+
JSON.stringify({
|
|
9
|
+
name: pkg.name,
|
|
10
|
+
bin: "pltt",
|
|
11
|
+
version: pkg.version,
|
|
12
|
+
}),
|
|
13
|
+
)
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(`pltt ${pkg.version}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = version
|
package/lib/css-scope.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const postcss = require("postcss")
|
|
4
|
+
const selectorParser = require("postcss-selector-parser")
|
|
5
|
+
|
|
6
|
+
function cssString(value) {
|
|
7
|
+
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function cssIdentifier(value) {
|
|
11
|
+
return String(value).replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function rootSelector(pluginId) {
|
|
15
|
+
return `[data-palette-plugin-root="${cssString(pluginId)}"]`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isKeyframesRule(rule) {
|
|
19
|
+
let parent = rule.parent
|
|
20
|
+
while (parent) {
|
|
21
|
+
if (parent.type === "atrule" && /keyframes$/i.test(parent.name)) return true
|
|
22
|
+
parent = parent.parent
|
|
23
|
+
}
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isRootLike(node) {
|
|
28
|
+
return (
|
|
29
|
+
(node.type === "tag" && /^(html|body)$/i.test(node.value)) ||
|
|
30
|
+
(node.type === "pseudo" && node.value === ":root")
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function removeLeadingCombinators(selector) {
|
|
35
|
+
while (selector.nodes[0]?.type === "combinator") {
|
|
36
|
+
selector.nodes[0].remove()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function replaceRootLikeSelectors(selector, rootNode) {
|
|
41
|
+
let replaced = false
|
|
42
|
+
selector.walk((node) => {
|
|
43
|
+
if (!isRootLike(node)) return
|
|
44
|
+
node.replaceWith(rootNode.clone())
|
|
45
|
+
replaced = true
|
|
46
|
+
})
|
|
47
|
+
return replaced
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function prefixSelector(selector, rootNode) {
|
|
51
|
+
if (!selector.nodes.length) return
|
|
52
|
+
if (replaceRootLikeSelectors(selector, rootNode)) {
|
|
53
|
+
removeLeadingCombinators(selector)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
selector.prepend(selectorParser.combinator({ value: " " }))
|
|
57
|
+
selector.prepend(rootNode.clone())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function scopeSelectors(selector, pluginId) {
|
|
61
|
+
const rootNode = selectorParser.attribute({
|
|
62
|
+
attribute: "data-palette-plugin-root",
|
|
63
|
+
operator: "=",
|
|
64
|
+
quoteMark: '"',
|
|
65
|
+
value: String(pluginId),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return selectorParser((selectors) => {
|
|
69
|
+
selectors.each((sel) => prefixSelector(sel, rootNode))
|
|
70
|
+
}).processSync(selector)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renameKeyframes(root, pluginId) {
|
|
74
|
+
const prefix = `palette-${cssIdentifier(pluginId)}-`
|
|
75
|
+
const names = new Map()
|
|
76
|
+
|
|
77
|
+
root.walkAtRules((atRule) => {
|
|
78
|
+
if (!/keyframes$/i.test(atRule.name)) return
|
|
79
|
+
const current = atRule.params.trim()
|
|
80
|
+
if (!current || /^["']/.test(current)) return
|
|
81
|
+
const next = `${prefix}${current}`
|
|
82
|
+
names.set(current, next)
|
|
83
|
+
atRule.params = next
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (names.size === 0) return
|
|
87
|
+
|
|
88
|
+
const namePattern = new RegExp(
|
|
89
|
+
`\\b(${Array.from(names.keys()).map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`,
|
|
90
|
+
"g",
|
|
91
|
+
)
|
|
92
|
+
root.walkDecls((decl) => {
|
|
93
|
+
if (!/^animation(-name)?$/i.test(decl.prop)) return
|
|
94
|
+
decl.value = decl.value.replace(namePattern, (match) => names.get(match) || match)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function scopePluginCss(css, pluginId) {
|
|
99
|
+
if (!css || !String(css).trim()) return ""
|
|
100
|
+
const root = postcss.parse(css)
|
|
101
|
+
|
|
102
|
+
renameKeyframes(root, pluginId)
|
|
103
|
+
root.walkRules((rule) => {
|
|
104
|
+
if (isKeyframesRule(rule)) return
|
|
105
|
+
rule.selector = scopeSelectors(rule.selector, pluginId)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return root.toString()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function appendCssExport(js, css) {
|
|
112
|
+
if (!css || !css.trim()) return js
|
|
113
|
+
const exportLine = `export const __palettePluginCss = ${JSON.stringify(css)};\n`
|
|
114
|
+
const sourceMapPattern = /\n?\/\/# sourceMappingURL=data:application\/json[^]*$/m
|
|
115
|
+
const match = js.match(sourceMapPattern)
|
|
116
|
+
if (!match) return `${js}\n${exportLine}`
|
|
117
|
+
return `${js.slice(0, match.index)}\n${exportLine}${match[0]}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
appendCssExport,
|
|
122
|
+
rootSelector,
|
|
123
|
+
scopePluginCss,
|
|
124
|
+
}
|
package/lib/dev-simulator.js
CHANGED
|
@@ -8,7 +8,7 @@ const { spawn, spawnSync } = require("child_process")
|
|
|
8
8
|
const { loadManifest } = require("./manifest")
|
|
9
9
|
const { frontendBuildConfig } = require("./bundler")
|
|
10
10
|
const { generatePaletteAppEntry } = require("./app-router")
|
|
11
|
-
const { loadLocalEnv } = require("./secrets")
|
|
11
|
+
const { declaredSecrets, loadLocalEnv } = require("./secrets")
|
|
12
12
|
|
|
13
13
|
function loadEsbuild() {
|
|
14
14
|
try {
|
|
@@ -51,6 +51,13 @@ function needsDatabase(manifest) {
|
|
|
51
51
|
return Boolean(manifest.database || manifest.capabilities?.database)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function databaseDriverDependencies(databaseUrl) {
|
|
55
|
+
const url = String(databaseUrl || "")
|
|
56
|
+
if (/^postgres(?:ql)?(?:\+asyncpg)?:/i.test(url)) return ["asyncpg>=0.29.0"]
|
|
57
|
+
if (/^mysql\+aiomysql:/i.test(url)) return ["aiomysql>=0.2.0"]
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
function loadLocalConnections(cwd, manifest) {
|
|
55
62
|
const declared = Array.isArray(manifest.connections) ? manifest.connections : []
|
|
56
63
|
const out = {}
|
|
@@ -103,13 +110,22 @@ function loadLocalAppServiceMocks(cwd) {
|
|
|
103
110
|
}
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
function resolveDevSecrets(cwd, manifest) {
|
|
114
|
+
const values = loadLocalEnv(cwd, { apply: false })
|
|
115
|
+
for (const name of Object.keys(declaredSecrets(manifest))) {
|
|
116
|
+
if (process.env[name] !== undefined) values[name] = process.env[name]
|
|
117
|
+
}
|
|
118
|
+
return values
|
|
119
|
+
}
|
|
120
|
+
|
|
106
121
|
function ensurePythonEnv(cwd, devDir, manifest) {
|
|
107
122
|
const hostPython = process.env.PALETTE_PYTHON || "python3"
|
|
108
123
|
const venvDir = path.join(devDir, "backend-venv")
|
|
109
124
|
const venvPython = path.join(venvDir, "bin", "python")
|
|
110
125
|
const lockPath = path.join(venvDir, ".palette-dev-deps-lock")
|
|
111
126
|
const dbDeps = needsDatabase(manifest) ? ["aiosqlite>=0.20.0", "greenlet>=3.0.0"] : []
|
|
112
|
-
const
|
|
127
|
+
const dbDriverDeps = needsDatabase(manifest) ? databaseDriverDependencies(process.env.PALETTE_DEV_DATABASE_URL) : []
|
|
128
|
+
const deps = Array.from(new Set([...pyprojectDependencies(cwd), ...dbDeps, ...dbDriverDeps, "uvicorn>=0.30.0"]))
|
|
113
129
|
const lock = JSON.stringify(deps)
|
|
114
130
|
|
|
115
131
|
if (!fs.existsSync(venvPython)) {
|
|
@@ -149,7 +165,7 @@ function writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort) {
|
|
|
149
165
|
const runner = path.join(devDir, "backend_runner.py")
|
|
150
166
|
const sdkPath = localBackendSdkPath()
|
|
151
167
|
const databasePath = path.join(devDir, `${manifest.id}.sqlite3`)
|
|
152
|
-
const devSecrets =
|
|
168
|
+
const devSecrets = resolveDevSecrets(cwd, manifest)
|
|
153
169
|
const devConnections = loadLocalConnections(cwd, manifest)
|
|
154
170
|
const devAppMocks = loadLocalAppServiceMocks(cwd)
|
|
155
171
|
const content = `from __future__ import annotations
|
|
@@ -615,6 +631,7 @@ function indexHtml(manifest) {
|
|
|
615
631
|
.palette-local-toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 50; }
|
|
616
632
|
.palette-local-toast { background: #1d1b18; color: white; padding: 10px 12px; font-size: 13px; box-shadow: 0 10px 30px rgba(0,0,0,.15); }
|
|
617
633
|
</style>
|
|
634
|
+
<link rel="stylesheet" href="/simulator.css" />
|
|
618
635
|
</head>
|
|
619
636
|
<body>
|
|
620
637
|
<div id="root"></div>
|
|
@@ -642,6 +659,7 @@ async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
|
|
|
642
659
|
if (!fs.existsSync(absEntry)) throw new Error(`frontend entry not found: ${entry}`)
|
|
643
660
|
const generatedEntry = path.join(devDir, "simulator-entry.jsx")
|
|
644
661
|
const bundlePath = path.join(devDir, "simulator.js")
|
|
662
|
+
const cssPath = path.join(devDir, "simulator.css")
|
|
645
663
|
fs.writeFileSync(generatedEntry, simulatorEntrySource(cwd, absEntry, manifest, backendPort))
|
|
646
664
|
const buildConfig = frontendBuildConfig(cwd, { ...(manifest.frontend || {}), entry })
|
|
647
665
|
|
|
@@ -658,6 +676,7 @@ async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
|
|
|
658
676
|
define: buildConfig.define,
|
|
659
677
|
absWorkingDir: cwd,
|
|
660
678
|
sourcemap: "inline",
|
|
679
|
+
metafile: true,
|
|
661
680
|
logLevel: "silent",
|
|
662
681
|
plugins: [
|
|
663
682
|
...buildConfig.plugins,
|
|
@@ -688,6 +707,17 @@ async function startFrontend(cwd, devDir, manifest, frontendPort, backendPort) {
|
|
|
688
707
|
res.end(fs.readFileSync(bundlePath))
|
|
689
708
|
return
|
|
690
709
|
}
|
|
710
|
+
if (url.pathname === "/simulator.css") {
|
|
711
|
+
console.log(`[pltt] frontend GET ${url.pathname} -> ${fs.existsSync(cssPath) ? 200 : 204}`)
|
|
712
|
+
if (!fs.existsSync(cssPath)) {
|
|
713
|
+
res.writeHead(204, { "Cache-Control": "no-store" })
|
|
714
|
+
res.end()
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
res.writeHead(200, { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-store" })
|
|
718
|
+
res.end(fs.readFileSync(cssPath))
|
|
719
|
+
return
|
|
720
|
+
}
|
|
691
721
|
console.log(`[pltt] frontend GET ${url.pathname} -> 200`)
|
|
692
722
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" })
|
|
693
723
|
res.end(indexHtml(manifest))
|
|
@@ -736,4 +766,4 @@ async function startSimulator({ cwd, frontendPort, backendPort }) {
|
|
|
736
766
|
await stop()
|
|
737
767
|
}
|
|
738
768
|
|
|
739
|
-
module.exports = { startSimulator }
|
|
769
|
+
module.exports = { databaseDriverDependencies, resolveDevSecrets, startSimulator }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@palettelab/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.51",
|
|
4
4
|
"description": "Developer CLI for building Palette platform plugins — no platform source access required.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pltt": "bin/pltt.js"
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"node": ">=18"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"esbuild": "^0.24.0"
|
|
26
|
+
"esbuild": "^0.24.0",
|
|
27
|
+
"postcss": "^8.5.15",
|
|
28
|
+
"postcss-selector-parser": "^7.1.1"
|
|
27
29
|
},
|
|
28
30
|
"publishConfig": {
|
|
29
31
|
"registry": "https://registry.npmjs.org"
|