@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 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 `test`.
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 `requires` contracts |
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
- | `AppInteropClient`, `ctx.apps` | Call required apps/services without direct database access |
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 depend on services from other installed
694
- apps. Declare provider capabilities with `provides`:
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": "org.hierarchy",
702
- "version": "1.0.0",
703
- "routes": [
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
- Consumers declare dependencies with `requires`:
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
- "requires": {
731
+ "consumes": {
718
732
  "services": [
719
- { "id": "org.hierarchy", "version": "^1.0.0", "reason": "Approval routing" }
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
- Then call through `ctx.apps`; never read another app's database directly. If
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
- ```python
730
- chain = await ctx.apps.service("org.hierarchy").get(f"/hierarchy/approval-chain/{ctx.user_id}")
731
- await ctx.events.publish("leave.requested", {"next_approver_id": chain["next_approver_id"]})
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
- Local mocks live in `.palette/app-services.local.json`:
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
- ```json
737
- {
738
- "org.hierarchy GET /hierarchy/approval-chain/dev-user": {
739
- "next_approver_id": "manager-1"
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
- ## 10. Lifecycle Hooks
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
- ## 11. Calling Backend APIs From Frontend
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
- ## 12. Local Development
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
- ## 13. Test In Real Palette OS Without Docker
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
- ## 14. Common Mistakes
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
- ## 15. Complete Minimal Example
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
- if (replace && typeof window !== "undefined") window.history.replaceState(null, "", osPath)
287
- else platform.navigate(osPath)
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 = isPaletteApp(frontend) ? fs.mkdtempSync(path.join(os.tmpdir(), "palette-app-entry-")) : null
397
- const bundleEntry = tmp
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 Buffer.from(result.outputFiles[0].contents)
494
+ return bundleOutputsToFrontendBuffer(result, pluginCssScopeId(frontend))
436
495
  } finally {
437
- if (tmp) fs.rmSync(tmp, { recursive: true, force: true })
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
@@ -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, manifest.frontend)
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: ${
@@ -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, manifest.frontend)
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(
@@ -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", manifest.frontend)
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
 
@@ -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, manifest.frontend)
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")
@@ -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, manifest.frontend)
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
@@ -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
+ }
@@ -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 deps = Array.from(new Set([...pyprojectDependencies(cwd), ...dbDeps, "uvicorn>=0.30.0"]))
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 = loadLocalEnv(cwd, { apply: false })
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.49",
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"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.20"
7
+ "@palettelab/sdk": "^0.1.21"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.20",
6
+ "@palettelab/sdk": "^0.1.21",
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.20", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.21", "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.20", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.21", "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.20",
6
+ "@palettelab/sdk": "^0.1.21",
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.20",
6
+ "@palettelab/sdk": "^0.1.21",
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.20",
6
+ "@palettelab/sdk": "^0.1.21",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {