@palettelab/cli 0.3.51 → 0.3.52

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.
Files changed (31) hide show
  1. package/README.md +9 -1
  2. package/backend-sdk/palette_sdk/__init__.py +4 -0
  3. package/backend-sdk/palette_sdk/events.py +16 -0
  4. package/backend-sdk/palette_sdk/manifest.py +2 -0
  5. package/backend-sdk/palette_sdk/services.py +26 -2
  6. package/lib/commands/init.js +1 -1
  7. package/lib/commands/services.js +255 -17
  8. package/lib/dev-simulator.js +12 -0
  9. package/lib/manifest.js +18 -2
  10. package/package.json +1 -1
  11. package/template-fallback/package.json +1 -1
  12. package/template-fallback/templates/consumer-app/README.md +10 -0
  13. package/template-fallback/templates/consumer-app/backend/api/main.py +15 -0
  14. package/template-fallback/templates/consumer-app/frontend/src/index.tsx +26 -0
  15. package/template-fallback/templates/consumer-app/package.json +9 -0
  16. package/template-fallback/templates/consumer-app/palette-plugin.json +45 -0
  17. package/template-fallback/templates/consumer-app/pyproject.toml +11 -0
  18. package/template-fallback/templates/dashboard/package.json +1 -1
  19. package/template-fallback/templates/database/package.json +1 -1
  20. package/template-fallback/templates/external-service/package.json +1 -1
  21. package/template-fallback/templates/frontend-only/package.json +1 -1
  22. package/template-fallback/templates/next/package.json +1 -1
  23. package/template-fallback/templates/palette-app/package.json +1 -1
  24. package/template-fallback/templates/provider-app/README.md +10 -0
  25. package/template-fallback/templates/provider-app/backend/api/main.py +25 -0
  26. package/template-fallback/templates/provider-app/package.json +8 -0
  27. package/template-fallback/templates/provider-app/palette-plugin.json +49 -0
  28. package/template-fallback/templates/provider-app/pyproject.toml +11 -0
  29. package/template-fallback/templates/provider-app/schemas/approvalChain.get.in.json +8 -0
  30. package/template-fallback/templates/provider-app/schemas/approvalChain.get.out.json +13 -0
  31. package/template-fallback/templates/provider-app/schemas/hierarchy.updated.json +8 -0
package/README.md CHANGED
@@ -784,9 +784,14 @@ Inspect and generate OS-broker service integrations declared through
784
784
  `provides` and `consumes` in `palette-plugin.json`.
785
785
 
786
786
  ```bash
787
+ pltt init employee-management --template provider-app
788
+ pltt init leave-management --template consumer-app
789
+ pltt services sync
787
790
  pltt services list --env staging
788
791
  pltt services add hr/v1#approvalChain.get --reason "Route approvals through HR"
789
792
  pltt services add hr/v1#hierarchy.updated --event --optional
793
+ pltt services mock hr/v1#approvalChain.get
794
+ pltt services test --offline
790
795
  pltt services pull --env staging
791
796
  pltt services scaffold approvalChain.get
792
797
  ```
@@ -795,7 +800,10 @@ Subcommands:
795
800
 
796
801
  - `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
802
  - `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`.
803
+ - `sync`: scans backend `@service(...)` decorators and updates `provides.services` so provider contracts do not drift from code.
804
+ - `mock <namespace/version#name>`: adds a local response to `.palette/app-services.local.json` for `pltt dev`.
805
+ - `test`: checks local consumes/provides/mocks and, unless `--offline` is set, verifies consumed targets against `/api/v1/os-broker/schemas`.
806
+ - `pull`: reads `consumes.services` and `consumes.events`, fetches JSON Schemas from `/api/v1/os-broker/schemas`, and generates nested `.palette/types/services.ts` plus Python helpers in `.palette/python/services.py`.
799
807
  - `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
808
 
801
809
  Generated TypeScript clients use the SDK's default `palette` client. Generated
@@ -38,6 +38,8 @@ from palette_sdk import events as events_module # also exposed as `palette_sdk.
38
38
  from palette_sdk.events import Event, EventPublisher, subscribe_event
39
39
  from palette_sdk.services import (
40
40
  BrokerCallError,
41
+ CrossAppGrantError,
42
+ MissingDependencyError,
41
43
  ServicesClient,
42
44
  service,
43
45
  services,
@@ -89,6 +91,8 @@ __all__ = [
89
91
  "services",
90
92
  "ServicesClient",
91
93
  "BrokerCallError",
94
+ "CrossAppGrantError",
95
+ "MissingDependencyError",
92
96
  "get_config",
93
97
  "require_config",
94
98
  "sign_webhook",
@@ -98,3 +98,19 @@ class EventPublisher:
98
98
  # Alias: `events.emit(...)` reads more naturally for app-owned events.
99
99
  async def emit(self, topic: str, payload: dict[str, Any] | None = None) -> None:
100
100
  await self.publish(topic, payload)
101
+
102
+ async def publish_durable(self, topic: str, payload: dict[str, Any] | None = None) -> None:
103
+ """Publish a business event through the broker durable-event API.
104
+
105
+ Current platform versions route this through the standard broker event
106
+ publisher. The method is intentionally stable so apps can adopt durable
107
+ delivery without changing call sites when queue-backed delivery is
108
+ enabled by the platform.
109
+ """
110
+ if hasattr(self._adapter, "publish_durable"):
111
+ await self._adapter.publish_durable(topic, payload or {})
112
+ return
113
+ await self.publish(topic, payload)
114
+
115
+ async def emit_durable(self, topic: str, payload: dict[str, Any] | None = None) -> None:
116
+ await self.publish_durable(topic, payload)
@@ -113,6 +113,8 @@ class AppServiceMethodSpec(BaseModel):
113
113
  output: dict[str, Any] | str | None = None
114
114
  route_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] | None = None
115
115
  route_path: str | None = None
116
+ deprecated: bool = False
117
+ replacement: str | None = None
116
118
 
117
119
 
118
120
  class ProvidedAppServiceSpec(BaseModel):
@@ -35,11 +35,15 @@ ServiceHandler = Callable[[Any, dict[str, Any]], Awaitable[Any]]
35
35
  class ServiceDecl:
36
36
  name: str
37
37
  handler: ServiceHandler
38
+ namespace: str | None = None
39
+ version: str | None = None
38
40
  scope: str | None = None
39
41
  label: str | None = None
40
42
  description: str | None = None
41
43
  input_schema: dict | None = None
42
44
  output_schema: dict | None = None
45
+ deprecated: bool = False
46
+ replacement: str | None = None
43
47
 
44
48
 
45
49
  _pending: list[ServiceDecl] = []
@@ -48,11 +52,17 @@ _pending: list[ServiceDecl] = []
48
52
  def service(
49
53
  name: str,
50
54
  *,
55
+ namespace: str | None = None,
56
+ version: str | None = None,
51
57
  scope: str | None = None,
52
58
  label: str | None = None,
53
59
  description: str | None = None,
54
60
  input_schema: dict | None = None,
61
+ input: dict | None = None,
55
62
  output_schema: dict | None = None,
63
+ output: dict | None = None,
64
+ deprecated: bool = False,
65
+ replacement: str | None = None,
56
66
  ) -> Callable[[ServiceHandler], ServiceHandler]:
57
67
  """Mark an async function as a broker-callable service method."""
58
68
 
@@ -61,11 +71,15 @@ def service(
61
71
  ServiceDecl(
62
72
  name=name,
63
73
  handler=fn,
74
+ namespace=namespace,
75
+ version=version,
64
76
  scope=scope,
65
77
  label=label,
66
78
  description=description,
67
- input_schema=input_schema,
68
- output_schema=output_schema,
79
+ input_schema=input_schema or input,
80
+ output_schema=output_schema or output,
81
+ deprecated=deprecated,
82
+ replacement=replacement,
69
83
  )
70
84
  )
71
85
  return fn
@@ -84,6 +98,14 @@ class BrokerCallError(RuntimeError):
84
98
  """Raised when the broker rejects or fails a service call."""
85
99
 
86
100
 
101
+ class MissingDependencyError(BrokerCallError):
102
+ """Raised by generated clients when a required provider app is missing."""
103
+
104
+
105
+ class CrossAppGrantError(BrokerCallError):
106
+ """Raised by generated clients when an org admin revoked a cross-app grant."""
107
+
108
+
87
109
  class ServicesClient:
88
110
  """Returned by `services(ctx)`. Use `.call(target, payload)` or sugar `.proxy(...)`."""
89
111
 
@@ -139,6 +161,8 @@ def services(ctx_or_adapter: Any) -> ServicesClient:
139
161
 
140
162
  __all__ = [
141
163
  "BrokerCallError",
164
+ "CrossAppGrantError",
165
+ "MissingDependencyError",
142
166
  "ServiceDecl",
143
167
  "ServicesClient",
144
168
  "drain_pending_services",
@@ -10,7 +10,7 @@ const DEFAULT_TEMPLATE_REPO = "palette-lab/plugin-template"
10
10
  const TEMPLATE_REPO = process.env.PALETTE_TEMPLATE_REPO || DEFAULT_TEMPLATE_REPO
11
11
  const TEMPLATE_REF = process.env.PALETTE_TEMPLATE_REF || "main"
12
12
 
13
- const KNOWN_TEMPLATES = ["frontend-only", "palette-app", "next", "dashboard", "agent-tool", "external-service", "database"]
13
+ const KNOWN_TEMPLATES = ["frontend-only", "palette-app", "next", "dashboard", "agent-tool", "external-service", "database", "provider-app", "consumer-app"]
14
14
 
15
15
  function toSlug(name) {
16
16
  return name
@@ -180,6 +180,54 @@ function safeIdent(s) {
180
180
  return s.replace(/[^A-Za-z0-9_]/g, "_")
181
181
  }
182
182
 
183
+ function safePythonIdent(s) {
184
+ return safeIdent(s).replace(/^(?=\d)/, "_")
185
+ }
186
+
187
+ function pythonMethodName(s) {
188
+ return safePythonIdent(
189
+ s
190
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
191
+ .replace(/[.-]+/g, "_")
192
+ .toLowerCase(),
193
+ )
194
+ }
195
+
196
+ function nestedAssign(root, dotted, value) {
197
+ const parts = dotted.split(".").filter(Boolean)
198
+ let cursor = root
199
+ while (parts.length > 1) {
200
+ const part = parts.shift()
201
+ cursor[part] = cursor[part] || {}
202
+ cursor = cursor[part]
203
+ }
204
+ cursor[parts[0] || dotted] = value
205
+ }
206
+
207
+ function renderTsObject(node, indent = " ") {
208
+ const lines = ["{"]
209
+ for (const [key, value] of Object.entries(node)) {
210
+ const prop = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
211
+ if (value && value.__service) {
212
+ if (value.scope) lines.push(`${indent}/** scope: ${value.scope} */`)
213
+ lines.push(`${indent}async ${prop}(input: ${value.inT}): Promise<${value.outT}> {`)
214
+ lines.push(`${indent} return (await palette.broker.call(${JSON.stringify(value.target)}, input)) as ${value.outT}`)
215
+ lines.push(`${indent}},`)
216
+ } else if (value && value.__event) {
217
+ lines.push(`${indent}${prop}(handler: (payload: ${value.payT}) => void) {`)
218
+ lines.push(`${indent} return palette.events.on(${JSON.stringify(value.target)}, (payload) => handler(payload as ${value.payT}))`)
219
+ lines.push(`${indent}},`)
220
+ } else {
221
+ const rendered = renderTsObject(value, indent + " ").split("\n")
222
+ lines.push(`${indent}${prop}: ${rendered[0]}`)
223
+ for (const line of rendered.slice(1)) lines.push(line)
224
+ lines[lines.length - 1] += ","
225
+ }
226
+ }
227
+ lines.push(`${indent.slice(0, -2)}}`)
228
+ return lines.join("\n")
229
+ }
230
+
183
231
  function tsHeader() {
184
232
  return (
185
233
  "// AUTOGENERATED by `pltt services pull` — do not edit by hand.\n" +
@@ -194,7 +242,7 @@ function pyHeader() {
194
242
  "from __future__ import annotations\n\n" +
195
243
  "from typing import Any\n\n" +
196
244
  "from pydantic import BaseModel\n" +
197
- "from palette_sdk import services as _services\n\n"
245
+ "from palette_sdk import services as _palette_services\n\n"
198
246
  )
199
247
  }
200
248
 
@@ -240,24 +288,28 @@ async function runPull(argv, { cwd }) {
240
288
  const services = items.filter((i) => i.kind === "service")
241
289
  const events = items.filter((i) => i.kind === "event")
242
290
  if (services.length) {
243
- tsLines.push(`export const ${safe} = {`)
291
+ const tree = {}
244
292
  for (const svc of services) {
245
- const comment = svc.scope ? ` /** scope: ${svc.scope} */` : ""
246
- if (comment) tsLines.push(comment)
247
- tsLines.push(` async ${JSON.stringify(svc.method)}(input: ${svc.inT}): Promise<${svc.outT}> {`)
248
- tsLines.push(` return (await palette.broker.call(${JSON.stringify(ns + "#" + svc.method)}, input)) as ${svc.outT}`)
249
- tsLines.push(` },`)
293
+ nestedAssign(tree, svc.method, {
294
+ __service: true,
295
+ target: `${ns}#${svc.method}`,
296
+ inT: svc.inT,
297
+ outT: svc.outT,
298
+ scope: svc.scope,
299
+ })
250
300
  }
251
- tsLines.push(`}\n`)
301
+ tsLines.push(`export const ${safe} = ${renderTsObject(tree)}\n`)
252
302
  }
253
303
  if (events.length) {
254
- tsLines.push(`export const ${safe}Events = {`)
304
+ const tree = {}
255
305
  for (const evt of events) {
256
- tsLines.push(` on${safeIdent(evt.topic).replace(/^(.)/, (m) => m.toUpperCase())}(handler: (payload: ${evt.payT}) => void) {`)
257
- tsLines.push(` return palette.events.on(${JSON.stringify(ns + "#" + evt.topic)}, (payload) => handler(payload as ${evt.payT}))`)
258
- tsLines.push(` },`)
306
+ nestedAssign(tree, evt.topic, {
307
+ __event: true,
308
+ target: `${ns}#${evt.topic}`,
309
+ payT: evt.payT,
310
+ })
259
311
  }
260
- tsLines.push(`}\n`)
312
+ tsLines.push(`export const ${safe}Events = ${renderTsObject(tree)}\n`)
261
313
  }
262
314
  }
263
315
  const tsDir = path.join(cwd, ".palette", "types")
@@ -270,7 +322,7 @@ async function runPull(argv, { cwd }) {
270
322
  const entry = byTarget.get(target)
271
323
  if (!entry) continue
272
324
  const safeNs = safeIdent(`${entry.namespace}_${entry.version}`)
273
- const safeMethod = safeIdent(entry.method || entry.topic)
325
+ const safeMethod = pythonMethodName(entry.method || entry.topic)
274
326
  if (entry.kind === "service") {
275
327
  const { code: inCode, ref: inRef } = jsonSchemaToPydantic(`${safeNs}_${safeMethod}_Input`, entry.input_schema)
276
328
  const { code: outCode, ref: outRef } = jsonSchemaToPydantic(`${safeNs}_${safeMethod}_Output`, entry.output_schema)
@@ -280,7 +332,7 @@ async function runPull(argv, { cwd }) {
280
332
  `async def ${safeNs}_${safeMethod}(ctx, payload: ${inRef}) -> ${outRef}:\n` +
281
333
  ` """Call ${entry.namespace}/${entry.version}#${entry.method}.\n\n` +
282
334
  (entry.scope ? ` Scope: ${entry.scope}\n """\n` : ` """\n`) +
283
- ` return await _services.services(ctx).call(${JSON.stringify(target)}, payload if isinstance(payload, dict) else payload.model_dump())\n`,
335
+ ` return await _palette_services(ctx).call(${JSON.stringify(target)}, payload if isinstance(payload, dict) else payload.model_dump())\n`,
284
336
  )
285
337
  } else if (entry.kind === "event") {
286
338
  const { code: payCode, ref: payRef } = jsonSchemaToPydantic(`${safeNs}_${safeMethod}_Payload`, entry.payload_schema)
@@ -303,6 +355,185 @@ async function runPull(argv, { cwd }) {
303
355
  if (missing.length) process.exit(2)
304
356
  }
305
357
 
358
+ // ---- sync provider contracts from Python decorators -------------------------
359
+
360
+ function listPythonFiles(dir, out = []) {
361
+ if (!fs.existsSync(dir)) return out
362
+ for (const name of fs.readdirSync(dir)) {
363
+ const p = path.join(dir, name)
364
+ const stat = fs.statSync(p)
365
+ if (stat.isDirectory()) {
366
+ if (![".venv", "__pycache__", ".palette", "node_modules"].includes(name)) listPythonFiles(p, out)
367
+ } else if (name.endsWith(".py")) {
368
+ out.push(p)
369
+ }
370
+ }
371
+ return out
372
+ }
373
+
374
+ function argValue(source, name) {
375
+ const match = source.match(new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`))
376
+ return match ? match[1] : null
377
+ }
378
+
379
+ function boolArg(source, name) {
380
+ return new RegExp(`${name}\\s*=\\s*True`).test(source)
381
+ }
382
+
383
+ function discoverServiceDecorators(cwd) {
384
+ const files = listPythonFiles(path.join(cwd, "backend"))
385
+ const found = []
386
+ for (const file of files) {
387
+ const text = fs.readFileSync(file, "utf8")
388
+ const re = /@service\s*\(([\s\S]*?)\)\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)/g
389
+ let match
390
+ while ((match = re.exec(text))) {
391
+ const args = match[1]
392
+ const name = (args.match(/["']([^"']+)["']/) || [])[1]
393
+ if (!name) continue
394
+ found.push({
395
+ name,
396
+ fn: match[2],
397
+ scope: argValue(args, "scope"),
398
+ label: argValue(args, "label"),
399
+ description: argValue(args, "description"),
400
+ namespace: argValue(args, "namespace"),
401
+ version: argValue(args, "version") || "v1",
402
+ input_schema: argValue(args, "input_schema") || argValue(args, "input"),
403
+ output_schema: argValue(args, "output_schema") || argValue(args, "output"),
404
+ deprecated: boolArg(args, "deprecated"),
405
+ replacement: argValue(args, "replacement"),
406
+ file: path.relative(cwd, file),
407
+ })
408
+ }
409
+ }
410
+ return found
411
+ }
412
+
413
+ async function runSync(argv, { cwd }) {
414
+ const manifest = readManifest(cwd)
415
+ const decorators = discoverServiceDecorators(cwd)
416
+ if (!decorators.length) {
417
+ console.log("[pltt] no @service(...) decorators found under backend/.")
418
+ return
419
+ }
420
+ manifest.provides = manifest.provides || {}
421
+ manifest.provides.namespace = valueAfter(argv, "--namespace") || decorators.find(d => d.namespace)?.namespace || manifest.provides.namespace || manifest.id
422
+ manifest.provides.services = manifest.provides.services || []
423
+ for (const decl of decorators) {
424
+ const serviceId = safeIdent(decl.name.split(".")[0] || decl.name).toLowerCase().replace(/_/g, ".")
425
+ let svc = manifest.provides.services.find(s => s && s.id === serviceId)
426
+ if (!svc) {
427
+ svc = { id: serviceId, version: decl.version || "v1", methods: [] }
428
+ manifest.provides.services.push(svc)
429
+ }
430
+ svc.version = svc.version || decl.version || "v1"
431
+ svc.methods = svc.methods || []
432
+ let method = svc.methods.find(m => m && m.name === decl.name)
433
+ if (!method) {
434
+ method = { name: decl.name }
435
+ svc.methods.push(method)
436
+ }
437
+ if (decl.scope) method.scope = decl.scope
438
+ if (decl.label) method.label = decl.label
439
+ if (decl.description) method.description = decl.description
440
+ if (decl.input_schema) method.input_schema = decl.input_schema
441
+ else method.input_schema = method.input_schema || `schemas/${decl.name}.in.json`
442
+ if (decl.output_schema) method.output_schema = decl.output_schema
443
+ else method.output_schema = method.output_schema || `schemas/${decl.name}.out.json`
444
+ if (decl.deprecated) method.deprecated = true
445
+ if (decl.replacement) method.replacement = decl.replacement
446
+ }
447
+ writeManifest(cwd, manifest)
448
+ console.log(`[pltt] synced ${decorators.length} service method(s) into palette-plugin.json`)
449
+ }
450
+
451
+ // ---- mock -------------------------------------------------------------------
452
+
453
+ function sampleFromSchema(schema) {
454
+ if (!schema || typeof schema !== "object") return {}
455
+ if (schema.example !== undefined) return schema.example
456
+ if (schema.type === "string") return "string"
457
+ if (schema.type === "integer" || schema.type === "number") return 1
458
+ if (schema.type === "boolean") return true
459
+ if (schema.type === "array") return [sampleFromSchema(schema.items)]
460
+ const props = schema.properties || {}
461
+ const out = {}
462
+ for (const [key, prop] of Object.entries(props)) out[key] = sampleFromSchema(prop)
463
+ return out
464
+ }
465
+
466
+ async function runMock(argv, { cwd }) {
467
+ const target = argv.find((arg) => !arg.startsWith("-"))
468
+ if (!target || !brokerTargetLooksValid(target)) {
469
+ console.error("[pltt] usage: pltt services mock <namespace/version#name>")
470
+ process.exit(1)
471
+ }
472
+ const mockPath = path.join(cwd, ".palette", "app-services.local.json")
473
+ ensureDir(path.dirname(mockPath))
474
+ const current = fs.existsSync(mockPath) ? JSON.parse(fs.readFileSync(mockPath, "utf8")) : {}
475
+ current[target] = current[target] || {
476
+ ok: true,
477
+ target,
478
+ items: [],
479
+ }
480
+ fs.writeFileSync(mockPath, JSON.stringify(current, null, 2) + "\n")
481
+ console.log(`[pltt] wrote local mock for ${target} -> ${path.relative(cwd, mockPath)}`)
482
+ }
483
+
484
+ // ---- test -------------------------------------------------------------------
485
+
486
+ function validateLocalMocks(cwd, targets) {
487
+ const mockPath = path.join(cwd, ".palette", "app-services.local.json")
488
+ if (!fs.existsSync(mockPath)) return []
489
+ const mocks = JSON.parse(fs.readFileSync(mockPath, "utf8"))
490
+ const errors = []
491
+ for (const key of Object.keys(mocks)) {
492
+ const target = key.split(" ")[0]
493
+ if (!brokerTargetLooksValid(target)) errors.push(`mock key is not a broker target: ${key}`)
494
+ if (!targets.has(target)) errors.push(`mock target is not declared in consumes: ${target}`)
495
+ }
496
+ return errors
497
+ }
498
+
499
+ async function runTest(argv, { cwd }) {
500
+ const manifest = readManifest(cwd)
501
+ const { services, events } = consumeTargets(manifest)
502
+ const targets = new Set([...services, ...events])
503
+ const errors = []
504
+ for (const target of targets) {
505
+ if (!brokerTargetLooksValid(target)) errors.push(`invalid broker target: ${target}`)
506
+ }
507
+ errors.push(...validateLocalMocks(cwd, targets))
508
+ const decorators = discoverServiceDecorators(cwd)
509
+ const provided = new Set()
510
+ const provides = manifest.provides || {}
511
+ const ns = provides.namespace || manifest.id
512
+ for (const svc of provides.services || []) {
513
+ for (const method of svc.methods || []) {
514
+ if (method.name) provided.add(`${ns}/${svc.version || "v1"}#${method.name}`)
515
+ }
516
+ }
517
+ for (const decl of decorators) {
518
+ const target = `${decl.namespace || ns}/${decl.version || "v1"}#${decl.name}`
519
+ if (!provided.has(target)) errors.push(`@service ${target} is not declared in provides; run pltt services sync`)
520
+ }
521
+ if (!argv.includes("--offline") && targets.size) {
522
+ const { flags } = parseFlags(argv)
523
+ const env = resolveEnvironment({ cwd, flags })
524
+ const schemas = await authFetch(env, `/api/v1/os-broker/schemas?targets=${encodeURIComponent(Array.from(targets).join(","))}`)
525
+ const returned = new Set(schemas.map(s => s.target))
526
+ for (const target of targets) {
527
+ if (!returned.has(target)) errors.push(`target not found in ${env.name || env.url}: ${target}`)
528
+ }
529
+ }
530
+ if (errors.length) {
531
+ for (const error of errors) console.error(`[pltt] ${error}`)
532
+ process.exit(1)
533
+ }
534
+ console.log(`[pltt] services contract check passed (${targets.size} consumed, ${decorators.length} provider decorator(s)).`)
535
+ }
536
+
306
537
  // ---- scaffold provider stub --------------------------------------------------
307
538
 
308
539
  async function runScaffold(argv, { cwd }) {
@@ -318,9 +549,10 @@ async function runScaffold(argv, { cwd }) {
318
549
  provides.namespace = namespace
319
550
  provides.services = provides.services || []
320
551
  // Ensure a default service slot.
321
- let svc = provides.services.find((s) => (s.id || "") === method) || null
552
+ const serviceId = safeIdent(method.split(".")[0] || method).toLowerCase().replace(/_/g, ".")
553
+ let svc = provides.services.find((s) => (s.id || "") === serviceId) || null
322
554
  if (!svc) {
323
- svc = { id: method, version: "v1", methods: [] }
555
+ svc = { id: serviceId, version: "v1", methods: [] }
324
556
  provides.services.push(svc)
325
557
  }
326
558
  svc.methods = svc.methods || []
@@ -410,11 +642,17 @@ async function run(argv, ctx) {
410
642
  const rest = argv.slice(1)
411
643
  if (sub === "list") return runList(rest, ctx)
412
644
  if (sub === "pull") return runPull(rest, ctx)
645
+ if (sub === "sync") return runSync(rest, ctx)
646
+ if (sub === "mock") return runMock(rest, ctx)
647
+ if (sub === "test") return runTest(rest, ctx)
413
648
  if (sub === "scaffold") return runScaffold(rest, ctx)
414
649
  if (sub === "add") return runAdd(rest, ctx)
415
650
  console.log("pltt services <command>\n")
416
651
  console.log(" list Show services/events available to the current org.")
417
652
  console.log(" pull Generate typed TS+Python clients from consumes block.")
653
+ console.log(" sync Update provides from backend @service decorators.")
654
+ console.log(" mock Add a local .palette/app-services.local.json response.")
655
+ console.log(" test Validate consumed targets, local mocks, and provider declarations.")
418
656
  console.log(" scaffold Add a new provider method + schemas + handler stub.")
419
657
  console.log(" add Add a consumed service/event target to palette-plugin.json.")
420
658
  if (sub && !["help", "--help", "-h"].includes(sub)) {
@@ -272,6 +272,18 @@ class LocalAppInteropService:
272
272
  return DEV_APP_MOCKS[key]
273
273
  raise RuntimeError("No local app service mock configured for " + key)
274
274
 
275
+ async def broker_call(self, target: str, payload=None):
276
+ payload = payload or {}
277
+ payload_key = target + " " + json.dumps(payload, sort_keys=True, separators=(",", ":"))
278
+ if payload_key in DEV_APP_MOCKS:
279
+ return DEV_APP_MOCKS[payload_key]
280
+ if target in DEV_APP_MOCKS:
281
+ return DEV_APP_MOCKS[target]
282
+ raise RuntimeError("No local broker mock configured for " + target)
283
+
284
+ async def broker_emit(self, target: str, payload=None):
285
+ print("[palette-broker-event]", target, json.dumps(payload or {}, sort_keys=True))
286
+
275
287
  class LocalAppServiceClient:
276
288
  def __init__(self, service_id: str):
277
289
  self.service_id = service_id
package/lib/manifest.js CHANGED
@@ -108,6 +108,12 @@ function requireString(obj, key, label, errors) {
108
108
  }
109
109
  }
110
110
 
111
+ function requireSchema(obj, key, label, errors) {
112
+ if (obj[key] !== undefined && typeof obj[key] !== "string" && !isObject(obj[key])) {
113
+ errors.push(`${label}.${key} must be a JSON schema object or schema file path`)
114
+ }
115
+ }
116
+
111
117
  function validateArray(value, label, errors) {
112
118
  if (value !== undefined && !Array.isArray(value)) errors.push(`${label} must be an array`)
113
119
  }
@@ -294,6 +300,8 @@ function validateServiceMethods(value, label, errors) {
294
300
  "output",
295
301
  "route_method",
296
302
  "route_path",
303
+ "deprecated",
304
+ "replacement",
297
305
  ]),
298
306
  methodLabel,
299
307
  errors,
@@ -304,9 +312,14 @@ function validateServiceMethods(value, label, errors) {
304
312
  errors.push(`duplicate provided method name: ${method.name}`)
305
313
  }
306
314
  seen.add(method.name)
307
- for (const key of ["scope", "label", "description", "input_schema", "input", "output_schema", "output", "route_path"]) {
315
+ for (const key of ["scope", "label", "description", "route_path"]) {
308
316
  requireString(method, key, methodLabel, errors)
309
317
  }
318
+ requireBoolean(method, "deprecated", methodLabel, errors)
319
+ requireString(method, "replacement", methodLabel, errors)
320
+ for (const key of ["input_schema", "input", "output_schema", "output"]) {
321
+ requireSchema(method, key, methodLabel, errors)
322
+ }
310
323
  if (method.route_method !== undefined && !methods.has(String(method.route_method).toUpperCase())) {
311
324
  errors.push(`${methodLabel}.route_method must be one of ${Array.from(methods).join(", ")}`)
312
325
  }
@@ -374,9 +387,12 @@ function validateProvides(value, errors) {
374
387
  } else if (isObject(event)) {
375
388
  unknownKeys(event, new Set(["topic", "name", "version", "payload_schema", "schema", "description"]), label, errors)
376
389
  topic = event.topic || event.name
377
- for (const key of ["version", "payload_schema", "schema", "description"]) {
390
+ for (const key of ["version", "description"]) {
378
391
  requireString(event, key, label, errors)
379
392
  }
393
+ for (const key of ["payload_schema", "schema"]) {
394
+ requireSchema(event, key, label, errors)
395
+ }
380
396
  } else {
381
397
  errors.push(`${label} must be a topic string or object`)
382
398
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.51",
3
+ "version": "0.3.52",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.21"
7
+ "@palettelab/sdk": "^0.1.22"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -0,0 +1,10 @@
1
+ # Consumer App Template
2
+
3
+ This template consumes `hr/v1#approvalChain.get`.
4
+
5
+ For local development without a provider app:
6
+
7
+ ```bash
8
+ pltt services mock hr/v1#approvalChain.get
9
+ pltt dev
10
+ ```
@@ -0,0 +1,15 @@
1
+ from fastapi import Depends
2
+ from palette_sdk import PluginContext, PluginRouter, get_plugin_context, services
3
+
4
+ router = PluginRouter()
5
+
6
+
7
+ @router.post("/leave-requests")
8
+ async def create_leave_request(payload: dict, ctx: PluginContext = Depends(get_plugin_context)):
9
+ chain = await services(ctx).call("hr/v1#approvalChain.get", {
10
+ "user_id": payload.get("user_id", ctx.user_id),
11
+ })
12
+ return {
13
+ "status": "submitted",
14
+ "approval_chain": chain,
15
+ }
@@ -0,0 +1,26 @@
1
+ import React, { useState } from "react"
2
+ import { createRoot } from "react-dom/client"
3
+ import { createPaletteClient, usePlatform } from "@palettelab/sdk"
4
+
5
+ function App() {
6
+ const platform = usePlatform()
7
+ const palette = createPaletteClient(platform)
8
+ const [result, setResult] = useState<string>("")
9
+
10
+ async function previewApprover() {
11
+ const chain = await palette.services("hr/v1").approvalChain.get({
12
+ user_id: platform.user.id,
13
+ })
14
+ setResult(JSON.stringify(chain, null, 2))
15
+ }
16
+
17
+ return (
18
+ <main style={{ padding: 24, fontFamily: "system-ui, sans-serif" }}>
19
+ <h1>Consumer App</h1>
20
+ <button onClick={previewApprover}>Preview approver</button>
21
+ {result && <pre>{result}</pre>}
22
+ </main>
23
+ )
24
+ }
25
+
26
+ createRoot(document.getElementById("root")!).render(<App />)
@@ -0,0 +1,9 @@
1
+ {
2
+ "private": true,
3
+ "type": "module",
4
+ "dependencies": {
5
+ "@palettelab/sdk": "^0.1.22",
6
+ "react": "^19.0.0",
7
+ "react-dom": "^19.0.0"
8
+ }
9
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "manifest_version": "1",
3
+ "id": "consumer-app",
4
+ "name": "Consumer App",
5
+ "version": "1.0.0",
6
+ "developer": "Palette",
7
+ "category": "Productivity",
8
+ "tagline": "Consume governed services from other apps",
9
+ "description": "Example consumer app that asks an HR provider for approval routing.",
10
+ "icon": "ClipboardText",
11
+ "gradient": {
12
+ "bg": "linear-gradient(135deg, #7C3AED, #DB2777)",
13
+ "text": "#ffffff"
14
+ },
15
+ "capabilities": {
16
+ "frontend": true,
17
+ "backend": true
18
+ },
19
+ "frontend": {
20
+ "entry": "./frontend/src/index.tsx"
21
+ },
22
+ "backend": {
23
+ "entry": "./backend/api/main.py"
24
+ },
25
+ "permissions": [
26
+ "resources:read",
27
+ "resources:write"
28
+ ],
29
+ "consumes": {
30
+ "services": [
31
+ {
32
+ "target": "hr/v1#approvalChain.get",
33
+ "required": true,
34
+ "reason": "Leave approvals follow the employee reporting hierarchy."
35
+ }
36
+ ],
37
+ "events": [
38
+ {
39
+ "target": "hr/v1#hierarchy.updated",
40
+ "required": false,
41
+ "reason": "Refresh approval routing when hierarchy changes."
42
+ }
43
+ ]
44
+ }
45
+ }
@@ -0,0 +1,11 @@
1
+ [project]
2
+ name = "consumer-app"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "palette-sdk"
7
+ ]
8
+
9
+ [build-system]
10
+ requires = ["setuptools>=75.0"]
11
+ build-backend = "setuptools.build_meta"
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.21",
6
+ "@palettelab/sdk": "^0.1.22",
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.21", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.22", "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.21", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.22", "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.21",
6
+ "@palettelab/sdk": "^0.1.22",
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.21",
6
+ "@palettelab/sdk": "^0.1.22",
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.21",
6
+ "@palettelab/sdk": "^0.1.22",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -0,0 +1,10 @@
1
+ # Provider App Template
2
+
3
+ This template exposes `hr/v1#approvalChain.get` through Palette's OS broker.
4
+
5
+ Run:
6
+
7
+ ```bash
8
+ pltt services sync
9
+ pltt dev
10
+ ```
@@ -0,0 +1,25 @@
1
+ from fastapi import Depends
2
+ from palette_sdk import PluginContext, PluginRouter, get_plugin_context, service
3
+
4
+ router = PluginRouter()
5
+
6
+
7
+ @service(
8
+ "approvalChain.get",
9
+ namespace="hr",
10
+ version="v1",
11
+ input_schema="schemas/approvalChain.get.in.json",
12
+ output_schema="schemas/approvalChain.get.out.json",
13
+ )
14
+ async def approval_chain(ctx: PluginContext, payload: dict) -> dict:
15
+ user_id = payload["user_id"]
16
+ return {
17
+ "user_id": user_id,
18
+ "next_approver_id": "manager-dev",
19
+ "levels": ["manager-dev", "hr-dev"],
20
+ }
21
+
22
+
23
+ @router.get("/health")
24
+ async def health(ctx: PluginContext = Depends(get_plugin_context)):
25
+ return {"ok": True, "plugin_id": ctx.plugin_id}
@@ -0,0 +1,8 @@
1
+ {
2
+ "private": true,
3
+ "type": "module",
4
+ "dependencies": {
5
+ "@palettelab/sdk": "^0.1.22",
6
+ "react": "^19.0.0"
7
+ }
8
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "manifest_version": "1",
3
+ "id": "provider-app",
4
+ "name": "Provider App",
5
+ "version": "1.0.0",
6
+ "developer": "Palette",
7
+ "category": "Productivity",
8
+ "tagline": "Expose governed services to other apps",
9
+ "description": "Example provider app exposing employee hierarchy through the OS broker.",
10
+ "icon": "UsersThree",
11
+ "gradient": {
12
+ "bg": "linear-gradient(135deg, #0F766E, #2563EB)",
13
+ "text": "#ffffff"
14
+ },
15
+ "capabilities": {
16
+ "backend": true
17
+ },
18
+ "backend": {
19
+ "entry": "./backend/api/main.py"
20
+ },
21
+ "permissions": [
22
+ "resources:read"
23
+ ],
24
+ "provides": {
25
+ "namespace": "hr",
26
+ "services": [
27
+ {
28
+ "id": "approval",
29
+ "version": "v1",
30
+ "label": "Approval hierarchy",
31
+ "description": "Resolve the next approver for an employee.",
32
+ "methods": [
33
+ {
34
+ "name": "approvalChain.get",
35
+ "input_schema": "schemas/approvalChain.get.in.json",
36
+ "output_schema": "schemas/approvalChain.get.out.json"
37
+ }
38
+ ]
39
+ }
40
+ ],
41
+ "events": [
42
+ {
43
+ "topic": "hierarchy.updated",
44
+ "version": "v1",
45
+ "schema": "schemas/hierarchy.updated.json"
46
+ }
47
+ ]
48
+ }
49
+ }
@@ -0,0 +1,11 @@
1
+ [project]
2
+ name = "provider-app"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "palette-sdk"
7
+ ]
8
+
9
+ [build-system]
10
+ requires = ["setuptools>=75.0"]
11
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "user_id": { "type": "string" }
6
+ },
7
+ "required": ["user_id"]
8
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "user_id": { "type": "string" },
6
+ "next_approver_id": { "type": "string" },
7
+ "levels": {
8
+ "type": "array",
9
+ "items": { "type": "string" }
10
+ }
11
+ },
12
+ "required": ["user_id", "next_approver_id", "levels"]
13
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "changed_user_id": { "type": "string" }
6
+ },
7
+ "required": ["changed_user_id"]
8
+ }