@palettelab/cli 0.3.48 → 0.3.49

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
@@ -533,10 +533,35 @@ Environment variables:
533
533
  | `PALETTE_DEV_LOG_TAIL` | `200` | Initial hosted log events shown by `pltt dev --sandbox` / `--cloud` |
534
534
  | `APPSTORE_AUTO_APPROVE_SANDBOX_PREVIEWS` | `false` | Backend setting for hosted sandboxes; auto-approve passing preview publishes so developers can test full OS behavior without manual review |
535
535
 
536
- ### `pltt secrets`
536
+ ### `.env` and secrets
537
537
 
538
- Palette secrets are declared in `palette-plugin.json` and resolved through
539
- `ctx.secret("NAME")`.
538
+ For normal app development, put environment values in root `.env` files and run
539
+ the usual CLI command. No separate input is required:
540
+
541
+ ```bash
542
+ pltt dev
543
+ pltt sandbox --env staging
544
+ pltt preview --env staging
545
+ pltt publish --env staging
546
+ ```
547
+
548
+ The CLI automatically reads these files, with later files overriding earlier
549
+ ones:
550
+
551
+ ```text
552
+ .env
553
+ .env.local
554
+ .env.<env>
555
+ .env.<env>.local
556
+ ```
557
+
558
+ Server-only keys such as `OPENAI_API_KEY` or `STRIPE_SECRET` are uploaded during
559
+ hosted preview/publish as encrypted plugin secrets and resolved through
560
+ `ctx.secret("NAME")`. Public keys prefixed with `NEXT_PUBLIC_` are bundled into
561
+ the frontend and are not uploaded as backend secrets.
562
+
563
+ You can still declare secrets explicitly in `palette-plugin.json` when you need
564
+ install-scoped values or labels/help text:
540
565
 
541
566
  ```json
542
567
  {
@@ -549,7 +574,7 @@ Palette secrets are declared in `palette-plugin.json` and resolved through
549
574
  }
550
575
  ```
551
576
 
552
- Commands:
577
+ Optional management commands:
553
578
 
554
579
  ```bash
555
580
  pltt secrets init
@@ -559,10 +584,9 @@ pltt secrets list --env staging
559
584
  pltt publish --env staging --secrets-file plugin-secrets.env
560
585
  ```
561
586
 
562
- `dev` secrets live in `.palette/.env.local`, are loaded by `pltt dev`, and are
563
- never uploaded. `plugin` secrets are encrypted by the platform and attached to
564
- the plugin/environment. `install` secrets are filled by the installing org.
565
- Frontend bundles may only receive public values such as `NEXT_PUBLIC_*`.
587
+ `.palette/.env.local` remains supported for older projects. `plugin` secrets
588
+ are encrypted by the platform and attached to the plugin/environment.
589
+ `install` secrets are filled by the installing org.
566
590
 
567
591
  Managed platform services do not require developer Redis/Qdrant keys. Declare
568
592
  them and use scoped SDK clients:
@@ -631,9 +631,10 @@ Declare secret ownership in `palette-plugin.json`:
631
631
  ```
632
632
 
633
633
  `ctx.secret("KEY")` resolves declared secrets from the configured scope:
634
- install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
635
- during `pltt dev`. Undeclared keys still fall back to the process environment
636
- for local compatibility.
634
+ install config, plugin-scope encrypted secrets, or local root `.env` files
635
+ during `pltt dev`. During hosted preview/publish, server-only `.env` keys are
636
+ uploaded automatically as encrypted plugin secrets. Undeclared keys still fall
637
+ back to the process environment for local compatibility.
637
638
 
638
639
  Managed Redis, vector, and storage services are declared in the manifest:
639
640
 
package/lib/bundler.js CHANGED
@@ -509,7 +509,7 @@ async function watchFrontend(pluginDir, entry, outfile, frontend = {}) {
509
509
  * ./backend/...
510
510
  * ./palette-plugin.json
511
511
  */
512
- async function bundleBackend(pluginDir) {
512
+ async function bundleBackend(pluginDir, options = {}) {
513
513
  pluginDir = path.resolve(pluginDir)
514
514
  const { spawnSync } = require("child_process")
515
515
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "palette-bundle-"))
@@ -531,9 +531,16 @@ async function bundleBackend(pluginDir) {
531
531
  const backendDir = path.join(pluginDir, "backend")
532
532
  if (fs.existsSync(backendDir)) copy(backendDir, path.join(stage, "backend"))
533
533
  for (const metadataFile of ["package.json", "pyproject.toml", "palette-plugin.json"]) {
534
+ if (metadataFile === "palette-plugin.json" && options.manifest) continue
534
535
  const src = path.join(pluginDir, metadataFile)
535
536
  if (fs.existsSync(src)) fs.copyFileSync(src, path.join(stage, metadataFile))
536
537
  }
538
+ if (options.manifest) {
539
+ fs.writeFileSync(
540
+ path.join(stage, "palette-plugin.json"),
541
+ JSON.stringify(options.manifest, null, 2),
542
+ )
543
+ }
537
544
 
538
545
  const sdkDir = path.resolve(__dirname, "..", "backend-sdk", "palette_sdk")
539
546
  const targetSdkDir = path.join(stage, "backend", "palette_sdk")
package/lib/cli.js CHANGED
@@ -11,6 +11,7 @@ const pkg = require("./commands/package")
11
11
  const status = require("./commands/status")
12
12
  const logs = require("./commands/logs")
13
13
  const secrets = require("./commands/secrets")
14
+ const services = require("./commands/services")
14
15
 
15
16
  const COMMANDS = {
16
17
  init: { run: init, help: "Scaffold a new plugin directory from the template" },
@@ -50,6 +51,10 @@ const COMMANDS = {
50
51
  run: secrets,
51
52
  help: "Initialize local env files and manage plugin-scope secrets",
52
53
  },
54
+ services: {
55
+ run: services,
56
+ help: "Inspect / pull / scaffold OS-broker services (list, pull, scaffold)",
57
+ },
53
58
  }
54
59
 
55
60
  function printHelp() {
@@ -77,6 +82,11 @@ function printHelp() {
77
82
  console.log("\nLogs flags:")
78
83
  console.log(" --tail <n> Tail last n events (default 50)")
79
84
  console.log(" -f, --follow Stream events (poll every 3s)")
85
+ console.log("\nServices (OS broker):")
86
+ console.log(" pltt services list # show provided services/events in the org")
87
+ console.log(" pltt services add TARGET # add a consumed service/event target")
88
+ console.log(" pltt services pull # generate typed TS+Python clients from consumes")
89
+ console.log(" pltt services scaffold NAME # add a provider method (schemas + handler stub)")
80
90
  console.log("\nSecrets:")
81
91
  console.log(" pltt secrets init")
82
92
  console.log(" pltt secrets set NAME --value <secret> --env staging")
@@ -6,7 +6,7 @@ const { spawnSync } = require("child_process")
6
6
  const { loadManifest, validateManifest } = require("../manifest")
7
7
  const { bundleFrontend } = require("../bundler")
8
8
  const { DEFAULT_BACKEND_DEV_PORT, DEFAULT_FRONTEND_DEV_PORT, resolveDevPorts } = require("../ports")
9
- const { declaredSecrets, loadLocalEnv } = require("../secrets")
9
+ const { declaredSecrets, loadLocalEnvDetails } = require("../secrets")
10
10
 
11
11
  const DEFAULT_IMAGE =
12
12
  process.env.PALETTE_DEV_IMAGE || "ghcr.io/palette-lab/platform-dev:latest"
@@ -122,19 +122,20 @@ async function run(args, { cwd }) {
122
122
  }
123
123
 
124
124
  const secrets = declaredSecrets(manifest)
125
- const localSecrets = loadLocalEnv(cwd, { apply: false })
125
+ const localEnv = loadLocalEnvDetails(cwd, { apply: false })
126
+ const localSecrets = localEnv.values
126
127
  if (Object.keys(secrets).length === 0) {
127
128
  ok("no manifest secrets declared")
128
129
  } else {
129
130
  ok(`manifest declares ${Object.keys(secrets).length} secret(s)`)
130
131
  for (const [name, spec] of Object.entries(secrets)) {
131
132
  if (spec.scope.includes("dev") && !localSecrets[name]) {
132
- warn(`local dev secret missing: ${name}`, "Run pltt secrets init and fill .palette/.env.local.")
133
+ warn(`local dev secret missing: ${name}`, "Add it to .env, .env.local, or an environment-specific .env file.")
133
134
  }
134
135
  if (spec.scope.includes("plugin") && spec.required && !localSecrets[name] && !process.env[name]) {
135
136
  warn(
136
137
  `plugin secret missing locally: ${name}`,
137
- "Set an env var, pass --secrets-file to publish, or run pltt secrets set after first publish.",
138
+ "Add it to .env/.env.local, set an env var, pass --secrets-file, or run pltt secrets set.",
138
139
  )
139
140
  }
140
141
  }
@@ -12,10 +12,13 @@ const {
12
12
  confirmProduction,
13
13
  } = require("../environments")
14
14
  const {
15
+ canAutoUploadEnvKey,
15
16
  declaredSecrets,
17
+ isPublicEnvKey,
18
+ isReservedAutoEnvKey,
19
+ loadLocalEnvDetails,
16
20
  loadLocalEnv,
17
21
  parseDotEnv,
18
- readDotEnvFile,
19
22
  redactValue,
20
23
  } = require("../secrets")
21
24
 
@@ -138,12 +141,12 @@ function printPreflightFailure(payload, fallbackOutput) {
138
141
  console.error("[pltt] Need machine-readable details? Run `pltt test --json`.")
139
142
  }
140
143
 
141
- function runPreflight(cwd, json, publishType = "release") {
144
+ function runPreflight(cwd, json, publishType = "release", environment) {
142
145
  const cliBin = path.resolve(__dirname, "..", "..", "bin", "pltt.js")
143
146
  const res = spawnSync(process.execPath, [cliBin, "test", "--json", "--publish-type", publishType], {
144
147
  cwd,
145
148
  encoding: "utf8",
146
- env: process.env,
149
+ env: environment ? { ...process.env, PALETTE_ENV: environment } : process.env,
147
150
  })
148
151
 
149
152
  if (res.status === 0) {
@@ -218,7 +221,18 @@ async function put(url, buf, contentType) {
218
221
  }
219
222
  }
220
223
 
221
- function collectPluginSecrets(cwd, manifest, env, flags, log) {
224
+ function scopesOf(spec) {
225
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) return ["dev"]
226
+ if (Array.isArray(spec.scope)) return spec.scope
227
+ if (typeof spec.scope === "string") return [spec.scope]
228
+ return ["dev"]
229
+ }
230
+
231
+ function cloneManifest(manifest) {
232
+ return JSON.parse(JSON.stringify(manifest))
233
+ }
234
+
235
+ function collectPluginSecrets(cwd, manifest, env, flags, log, localEnv) {
222
236
  const declared = declaredSecrets(manifest)
223
237
  const pluginSecrets = Object.entries(declared).filter(([, spec]) => spec.scope.includes("plugin"))
224
238
  const devRequired = Object.entries(declared).filter(([, spec]) => spec.scope.includes("dev") && spec.required)
@@ -232,7 +246,8 @@ function collectPluginSecrets(cwd, manifest, env, flags, log) {
232
246
  if (flags.secretsFile) {
233
247
  fileValues = parseDotEnv(fs.readFileSync(path.resolve(cwd, flags.secretsFile), "utf8"))
234
248
  }
235
- const localValues = readDotEnvFile(path.join(cwd, ".palette", ".env.local"))
249
+ const localValues = localEnv?.values || loadLocalEnv(cwd, { apply: false, environment: env.name })
250
+ const candidateValues = { ...localValues, ...fileValues }
236
251
  const values = {}
237
252
  const missing = []
238
253
  for (const [name, spec] of pluginSecrets) {
@@ -249,10 +264,61 @@ function collectPluginSecrets(cwd, manifest, env, flags, log) {
249
264
  `Set env vars, pass --secrets-file, or run pltt secrets set <NAME> --env ${env.name}.`,
250
265
  )
251
266
  }
267
+ const effectiveManifest = cloneManifest(manifest)
268
+ effectiveManifest.secrets =
269
+ effectiveManifest.secrets && typeof effectiveManifest.secrets === "object" && !Array.isArray(effectiveManifest.secrets)
270
+ ? { ...effectiveManifest.secrets }
271
+ : {}
272
+ const autoUploaded = []
273
+ const publicBundled = []
274
+ const skippedReserved = []
275
+ const skippedInvalid = []
276
+ for (const [name, value] of Object.entries(candidateValues)) {
277
+ if (!value) continue
278
+ if (isPublicEnvKey(name)) {
279
+ publicBundled.push(name)
280
+ continue
281
+ }
282
+ const explicitSpec = effectiveManifest.secrets[name]
283
+ if (explicitSpec) {
284
+ if (scopesOf(explicitSpec).includes("plugin")) values[name] = fileValues[name] ?? process.env[name] ?? localValues[name]
285
+ continue
286
+ }
287
+ if (isReservedAutoEnvKey(name)) {
288
+ skippedReserved.push(name)
289
+ continue
290
+ }
291
+ if (!canAutoUploadEnvKey(name)) {
292
+ skippedInvalid.push(name)
293
+ continue
294
+ }
295
+ effectiveManifest.secrets[name] = {
296
+ scope: "plugin",
297
+ required: false,
298
+ help: "Auto-uploaded from local .env by Palette CLI.",
299
+ }
300
+ values[name] = fileValues[name] ?? process.env[name] ?? localValues[name]
301
+ autoUploaded.push(name)
302
+ }
303
+ if (localEnv?.files?.length) {
304
+ log(`[pltt] env files: ${localEnv.files.join(", ")}`)
305
+ }
306
+ if (autoUploaded.length) {
307
+ log(`[pltt] auto plugin secrets from .env: ${autoUploaded.sort().join(", ")}`)
308
+ }
309
+ if (publicBundled.length) {
310
+ log(`[pltt] public env bundled in frontend: ${Array.from(new Set(publicBundled)).sort().join(", ")}`)
311
+ }
312
+ if (skippedReserved.length) {
313
+ log(`[pltt] skipped reserved env keys: ${Array.from(new Set(skippedReserved)).sort().join(", ")}`)
314
+ }
315
+ if (skippedInvalid.length) {
316
+ log(`[pltt] skipped invalid env keys: ${Array.from(new Set(skippedInvalid)).sort().join(", ")}`)
317
+ }
252
318
  for (const [name, value] of Object.entries(values)) {
253
319
  log(`[pltt] plugin secret ${name}=${redactValue(value)} (${env.name})`)
254
320
  }
255
- return values
321
+ return { manifest: effectiveManifest, pluginSecrets: values }
256
322
  }
257
323
 
258
324
  async function run(argv, { cwd }) {
@@ -288,7 +354,7 @@ async function run(argv, { cwd }) {
288
354
 
289
355
  const manifest = loadManifest(cwd)
290
356
  const publishType = parsePublishType(argv)
291
- loadLocalEnv(cwd)
357
+ const localEnv = loadLocalEnvDetails(cwd, { environment: env.name })
292
358
  const errors = validateManifest(manifest)
293
359
  if (errors.length) {
294
360
  console.error("[pltt] manifest invalid:")
@@ -296,7 +362,23 @@ async function run(argv, { cwd }) {
296
362
  process.exit(1)
297
363
  }
298
364
 
299
- runPreflight(cwd, flags.json, publishType)
365
+ runPreflight(cwd, flags.json, publishType, env.name)
366
+ let pluginSecrets = {}
367
+ let publishManifest = manifest
368
+ try {
369
+ const collected = collectPluginSecrets(cwd, manifest, env, flags, log, localEnv)
370
+ pluginSecrets = collected.pluginSecrets
371
+ publishManifest = collected.manifest
372
+ } catch (err) {
373
+ console.error(`[pltt] ${err instanceof Error ? err.message : String(err)}`)
374
+ process.exit(1)
375
+ }
376
+ const effectiveErrors = validateManifest(publishManifest)
377
+ if (effectiveErrors.length) {
378
+ console.error("[pltt] generated manifest invalid:")
379
+ for (const e of effectiveErrors) console.error(` - ${e}`)
380
+ process.exit(1)
381
+ }
300
382
 
301
383
  log(
302
384
  `[pltt] publishing ${manifest.id}@${manifest.version} → ${env.name} (${env.url})`,
@@ -312,18 +394,11 @@ async function run(argv, { cwd }) {
312
394
  }
313
395
 
314
396
  log("[pltt] bundling backend")
315
- const backend = await bundleBackend(cwd)
397
+ const backend = await bundleBackend(cwd, { manifest: publishManifest })
316
398
  log(`[pltt] ${backend.length} bytes`)
317
399
 
318
400
  const backendSha = sha256(backend)
319
401
  const api = makeApi(env)
320
- let pluginSecrets = {}
321
- try {
322
- pluginSecrets = collectPluginSecrets(cwd, manifest, env, flags, log)
323
- } catch (err) {
324
- console.error(`[pltt] ${err instanceof Error ? err.message : String(err)}`)
325
- process.exit(1)
326
- }
327
402
 
328
403
  log("[pltt] requesting signed URLs")
329
404
  const signed = await api("/api/v1/appstore/sign-upload", {
@@ -341,7 +416,7 @@ async function run(argv, { cwd }) {
341
416
  put(signed.backend_upload_url, backend, "application/gzip"),
342
417
  put(
343
418
  signed.manifest_upload_url,
344
- Buffer.from(JSON.stringify(manifest, null, 2)),
419
+ Buffer.from(JSON.stringify(publishManifest, null, 2)),
345
420
  "application/json",
346
421
  ),
347
422
  ]
@@ -356,7 +431,7 @@ async function run(argv, { cwd }) {
356
431
  version: manifest.version,
357
432
  bundle_path: signed.bundle_path,
358
433
  bundle_sha256: backendSha,
359
- manifest,
434
+ manifest: publishManifest,
360
435
  publish_type: publishType,
361
436
  environment: env.name,
362
437
  }
@@ -0,0 +1,426 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * pltt services — OS broker developer tools.
5
+ *
6
+ * pltt services list — show every service/event the org's installed
7
+ * apps make available; useful for "what can I
8
+ * consume?".
9
+ * pltt services pull — read consumes block from palette-plugin.json,
10
+ * fetch JSON Schemas from the platform, and
11
+ * generate typed clients into:
12
+ * .palette/types/services.d.ts
13
+ * .palette/types/services.ts (proxy)
14
+ * .palette/python/services.py (pydantic)
15
+ * pltt services scaffold <method>
16
+ * — emit a provider stub (schemas + handler) in
17
+ * the current plugin under ./broker/.
18
+ * pltt services add <target>
19
+ * — add a consumed service/event target to
20
+ * palette-plugin.json.
21
+ */
22
+
23
+ const fs = require("fs")
24
+ const path = require("path")
25
+ const { parseFlags, resolveEnvironment } = require("../environments")
26
+
27
+ function readManifest(cwd) {
28
+ const p = path.join(cwd, "palette-plugin.json")
29
+ if (!fs.existsSync(p)) {
30
+ throw new Error("no palette-plugin.json found in current directory")
31
+ }
32
+ return JSON.parse(fs.readFileSync(p, "utf8"))
33
+ }
34
+
35
+ function writeManifest(cwd, manifest) {
36
+ const p = path.join(cwd, "palette-plugin.json")
37
+ fs.writeFileSync(p, JSON.stringify(manifest, null, 2) + "\n")
38
+ }
39
+
40
+ function ensureDir(p) {
41
+ fs.mkdirSync(p, { recursive: true })
42
+ }
43
+
44
+ async function authFetch(env, path, init = {}) {
45
+ const url = `${env.url}${path}`
46
+ const headers = { "Content-Type": "application/json", ...(init.headers || {}) }
47
+ if (env.token) headers.Authorization = `Bearer ${env.token}`
48
+ const res = await fetch(url, { ...init, headers })
49
+ if (!res.ok) {
50
+ const text = await res.text()
51
+ throw new Error(`${init.method || "GET"} ${url} → ${res.status}: ${text}`)
52
+ }
53
+ return res.json()
54
+ }
55
+
56
+ // ---- list --------------------------------------------------------------------
57
+
58
+ async function runList(argv, { cwd }) {
59
+ const { flags } = parseFlags(argv)
60
+ const env = resolveEnvironment({ cwd, flags })
61
+ const catalog = await authFetch(env, "/api/v1/os-broker/catalog")
62
+ if (argv.includes("--json")) {
63
+ process.stdout.write(JSON.stringify(catalog, null, 2) + "\n")
64
+ return
65
+ }
66
+ const byNs = {}
67
+ for (const svc of catalog.services || []) {
68
+ const key = `${svc.namespace}/${svc.version}`
69
+ byNs[key] = byNs[key] || { services: [], events: [], providers: new Set() }
70
+ byNs[key].services.push(svc)
71
+ byNs[key].providers.add(svc.provider_app_id)
72
+ }
73
+ for (const evt of catalog.events || []) {
74
+ const key = `${evt.namespace}/${evt.version}`
75
+ byNs[key] = byNs[key] || { services: [], events: [], providers: new Set() }
76
+ byNs[key].events.push(evt)
77
+ byNs[key].providers.add(evt.provider_app_id)
78
+ }
79
+ const keys = Object.keys(byNs).sort()
80
+ if (keys.length === 0) {
81
+ console.log("[pltt] no services or events registered for the current org.")
82
+ return
83
+ }
84
+ for (const key of keys) {
85
+ const entry = byNs[key]
86
+ console.log(`\n${key} (provider: ${Array.from(entry.providers).join(", ")})`)
87
+ for (const svc of entry.services) {
88
+ const scope = svc.scope ? ` [scope: ${svc.scope}]` : ""
89
+ console.log(` service ${key}#${svc.method}${scope}`)
90
+ if (svc.description) console.log(` ${svc.description}`)
91
+ }
92
+ for (const evt of entry.events) {
93
+ console.log(` event ${key}#${evt.topic}`)
94
+ if (evt.description) console.log(` ${evt.description}`)
95
+ }
96
+ }
97
+ }
98
+
99
+ // ---- pull (codegen) ----------------------------------------------------------
100
+
101
+ function consumeTargets(manifest) {
102
+ const consumes = manifest.consumes || {}
103
+ const normalize = (entry) => {
104
+ if (typeof entry === "string") return entry
105
+ if (entry && typeof entry === "object" && typeof entry.target === "string") return entry.target
106
+ return null
107
+ }
108
+ const services = (consumes.services || []).map(normalize).filter(Boolean)
109
+ const events = (consumes.events || []).map(normalize).filter(Boolean)
110
+ return { services, events }
111
+ }
112
+
113
+ function targetFromConsumeEntry(entry) {
114
+ if (typeof entry === "string") return entry
115
+ if (entry && typeof entry === "object" && typeof entry.target === "string") return entry.target
116
+ return null
117
+ }
118
+
119
+ function brokerTargetLooksValid(target) {
120
+ return typeof target === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[A-Za-z0-9_.-]+$/.test(target)
121
+ }
122
+
123
+ function valueAfter(argv, name) {
124
+ const index = argv.indexOf(name)
125
+ if (index === -1) return null
126
+ return argv[index + 1] || null
127
+ }
128
+
129
+ function jsonSchemaToTs(schema, indent = "") {
130
+ if (!schema || typeof schema !== "object") return "any"
131
+ if (schema.type === "string") return "string"
132
+ if (schema.type === "number" || schema.type === "integer") return "number"
133
+ if (schema.type === "boolean") return "boolean"
134
+ if (schema.type === "array") return `${jsonSchemaToTs(schema.items)}[]`
135
+ if (schema.type === "object" || schema.properties) {
136
+ const props = schema.properties || {}
137
+ const required = new Set(schema.required || [])
138
+ const lines = []
139
+ for (const [name, prop] of Object.entries(props)) {
140
+ const opt = required.has(name) ? "" : "?"
141
+ lines.push(`${indent} ${JSON.stringify(name)}${opt}: ${jsonSchemaToTs(prop, indent + " ")}`)
142
+ }
143
+ return `{\n${lines.join("\n")}\n${indent}}`
144
+ }
145
+ return "any"
146
+ }
147
+
148
+ function jsonSchemaToPydantic(name, schema) {
149
+ if (!schema || typeof schema !== "object") {
150
+ return { code: "", ref: "dict" }
151
+ }
152
+ if (schema.type === "object" || schema.properties) {
153
+ const props = schema.properties || {}
154
+ const required = new Set(schema.required || [])
155
+ const fieldLines = []
156
+ for (const [field, prop] of Object.entries(props)) {
157
+ const opt = required.has(field) ? "" : " | None = None"
158
+ fieldLines.push(` ${field}: ${pythonScalar(prop)}${opt}`)
159
+ }
160
+ const code =
161
+ `class ${name}(BaseModel):\n` +
162
+ (fieldLines.length ? fieldLines.join("\n") : " pass") +
163
+ "\n"
164
+ return { code, ref: name }
165
+ }
166
+ return { code: "", ref: pythonScalar(schema) }
167
+ }
168
+
169
+ function pythonScalar(schema) {
170
+ if (!schema) return "Any"
171
+ if (schema.type === "string") return "str"
172
+ if (schema.type === "integer") return "int"
173
+ if (schema.type === "number") return "float"
174
+ if (schema.type === "boolean") return "bool"
175
+ if (schema.type === "array") return `list[${pythonScalar(schema.items)}]`
176
+ return "Any"
177
+ }
178
+
179
+ function safeIdent(s) {
180
+ return s.replace(/[^A-Za-z0-9_]/g, "_")
181
+ }
182
+
183
+ function tsHeader() {
184
+ return (
185
+ "// AUTOGENERATED by `pltt services pull` — do not edit by hand.\n" +
186
+ "// Re-run after adding to consumes.services / consumes.events.\n\n" +
187
+ 'import { palette } from "@palettelab/sdk"\n\n'
188
+ )
189
+ }
190
+
191
+ function pyHeader() {
192
+ return (
193
+ '"""AUTOGENERATED by `pltt services pull` — do not edit by hand."""\n\n' +
194
+ "from __future__ import annotations\n\n" +
195
+ "from typing import Any\n\n" +
196
+ "from pydantic import BaseModel\n" +
197
+ "from palette_sdk import services as _services\n\n"
198
+ )
199
+ }
200
+
201
+ async function runPull(argv, { cwd }) {
202
+ const manifest = readManifest(cwd)
203
+ const { services, events } = consumeTargets(manifest)
204
+ const targets = [...new Set([...services, ...events])]
205
+ if (targets.length === 0) {
206
+ console.log("[pltt] palette-plugin.json declares no consumes.services or consumes.events — nothing to pull.")
207
+ return
208
+ }
209
+ const { flags } = parseFlags(argv)
210
+ const env = resolveEnvironment({ cwd, flags })
211
+ const targetsParam = encodeURIComponent(targets.join(","))
212
+ const schemas = await authFetch(env, `/api/v1/os-broker/schemas?targets=${targetsParam}`)
213
+
214
+ const byTarget = new Map(schemas.map((s) => [s.target, s]))
215
+ const missing = targets.filter((t) => !byTarget.has(t))
216
+ if (missing.length) {
217
+ console.warn(`[pltt] warning: no schema returned for: ${missing.join(", ")}`)
218
+ }
219
+
220
+ // ---- TypeScript output
221
+ const tsLines = [tsHeader()]
222
+ const tsProxies = {}
223
+ for (const target of targets) {
224
+ const entry = byTarget.get(target)
225
+ if (!entry) continue
226
+ const proxyKey = `${entry.namespace}/${entry.version}`
227
+ tsProxies[proxyKey] = tsProxies[proxyKey] || []
228
+ if (entry.kind === "service") {
229
+ const inT = entry.input_schema ? jsonSchemaToTs(entry.input_schema) : "Record<string, unknown>"
230
+ const outT = entry.output_schema ? jsonSchemaToTs(entry.output_schema) : "unknown"
231
+ tsProxies[proxyKey].push({ kind: "service", method: entry.method, inT, outT, scope: entry.scope })
232
+ } else if (entry.kind === "event") {
233
+ const payT = entry.payload_schema ? jsonSchemaToTs(entry.payload_schema) : "Record<string, unknown>"
234
+ tsProxies[proxyKey].push({ kind: "event", topic: entry.topic, payT })
235
+ }
236
+ }
237
+ for (const [ns, items] of Object.entries(tsProxies)) {
238
+ const safe = safeIdent(ns)
239
+ tsLines.push(`// ----- ${ns} -----`)
240
+ const services = items.filter((i) => i.kind === "service")
241
+ const events = items.filter((i) => i.kind === "event")
242
+ if (services.length) {
243
+ tsLines.push(`export const ${safe} = {`)
244
+ 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(` },`)
250
+ }
251
+ tsLines.push(`}\n`)
252
+ }
253
+ if (events.length) {
254
+ tsLines.push(`export const ${safe}Events = {`)
255
+ 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(` },`)
259
+ }
260
+ tsLines.push(`}\n`)
261
+ }
262
+ }
263
+ const tsDir = path.join(cwd, ".palette", "types")
264
+ ensureDir(tsDir)
265
+ fs.writeFileSync(path.join(tsDir, "services.ts"), tsLines.join("\n"))
266
+
267
+ // ---- Python output
268
+ const pyParts = [pyHeader()]
269
+ for (const target of targets) {
270
+ const entry = byTarget.get(target)
271
+ if (!entry) continue
272
+ const safeNs = safeIdent(`${entry.namespace}_${entry.version}`)
273
+ const safeMethod = safeIdent(entry.method || entry.topic)
274
+ if (entry.kind === "service") {
275
+ const { code: inCode, ref: inRef } = jsonSchemaToPydantic(`${safeNs}_${safeMethod}_Input`, entry.input_schema)
276
+ const { code: outCode, ref: outRef } = jsonSchemaToPydantic(`${safeNs}_${safeMethod}_Output`, entry.output_schema)
277
+ if (inCode) pyParts.push(inCode)
278
+ if (outCode) pyParts.push(outCode)
279
+ pyParts.push(
280
+ `async def ${safeNs}_${safeMethod}(ctx, payload: ${inRef}) -> ${outRef}:\n` +
281
+ ` """Call ${entry.namespace}/${entry.version}#${entry.method}.\n\n` +
282
+ (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`,
284
+ )
285
+ } else if (entry.kind === "event") {
286
+ const { code: payCode, ref: payRef } = jsonSchemaToPydantic(`${safeNs}_${safeMethod}_Payload`, entry.payload_schema)
287
+ if (payCode) pyParts.push(payCode)
288
+ pyParts.push(
289
+ `def on_${safeNs}_${safeMethod}(handler):\n` +
290
+ ` """Subscribe to ${target}. Returns the underlying SDK subscribe call."""\n` +
291
+ ` from palette_sdk.events import subscribe_event\n` +
292
+ ` subscribe_event(${JSON.stringify(target)}, handler)\n`,
293
+ )
294
+ }
295
+ }
296
+ const pyDir = path.join(cwd, ".palette", "python")
297
+ ensureDir(pyDir)
298
+ fs.writeFileSync(path.join(pyDir, "services.py"), pyParts.join("\n"))
299
+
300
+ console.log(`[pltt] generated:`)
301
+ console.log(` ${path.relative(cwd, path.join(tsDir, "services.ts"))}`)
302
+ console.log(` ${path.relative(cwd, path.join(pyDir, "services.py"))}`)
303
+ if (missing.length) process.exit(2)
304
+ }
305
+
306
+ // ---- scaffold provider stub --------------------------------------------------
307
+
308
+ async function runScaffold(argv, { cwd }) {
309
+ const positional = argv.filter((a) => !a.startsWith("-"))
310
+ const method = positional[0]
311
+ if (!method) {
312
+ console.error("[pltt] usage: pltt services scaffold <method>")
313
+ process.exit(1)
314
+ }
315
+ const manifest = readManifest(cwd)
316
+ const namespace = (manifest.provides && manifest.provides.namespace) || manifest.id
317
+ const provides = manifest.provides || {}
318
+ provides.namespace = namespace
319
+ provides.services = provides.services || []
320
+ // Ensure a default service slot.
321
+ let svc = provides.services.find((s) => (s.id || "") === method) || null
322
+ if (!svc) {
323
+ svc = { id: method, version: "v1", methods: [] }
324
+ provides.services.push(svc)
325
+ }
326
+ svc.methods = svc.methods || []
327
+ if (!svc.methods.find((m) => m && m.name === method)) {
328
+ svc.methods.push({
329
+ name: method,
330
+ scope: `${namespace}.${method}`,
331
+ input_schema: `schemas/${method}.in.json`,
332
+ output_schema: `schemas/${method}.out.json`,
333
+ })
334
+ }
335
+ manifest.provides = provides
336
+ writeManifest(cwd, manifest)
337
+
338
+ const schemasDir = path.join(cwd, "schemas")
339
+ ensureDir(schemasDir)
340
+ for (const suffix of ["in", "out"]) {
341
+ const p = path.join(schemasDir, `${method}.${suffix}.json`)
342
+ if (!fs.existsSync(p)) {
343
+ fs.writeFileSync(
344
+ p,
345
+ JSON.stringify(
346
+ {
347
+ $schema: "https://json-schema.org/draft/2020-12/schema",
348
+ type: "object",
349
+ properties: {},
350
+ required: [],
351
+ },
352
+ null,
353
+ 2,
354
+ ) + "\n",
355
+ )
356
+ }
357
+ }
358
+
359
+ const brokerDir = path.join(cwd, "broker")
360
+ ensureDir(brokerDir)
361
+ const handlerPath = path.join(brokerDir, `${safeIdent(method)}.py`)
362
+ if (!fs.existsSync(handlerPath)) {
363
+ fs.writeFileSync(
364
+ handlerPath,
365
+ `from palette_sdk import service, PluginContext\n\n` +
366
+ `@service(${JSON.stringify(method)}, scope=${JSON.stringify(namespace + "." + method)})\n` +
367
+ `async def ${safeIdent(method)}(ctx: PluginContext, payload: dict) -> dict:\n` +
368
+ ` """Provider handler for ${namespace}/v1#${method}.\n\n` +
369
+ ` The OS broker validates payload against schemas/${method}.in.json and the\n` +
370
+ ` response against schemas/${method}.out.json before routing.\n """\n` +
371
+ ` return {}\n`,
372
+ )
373
+ }
374
+ console.log(`[pltt] scaffolded ${namespace}/v1#${method}`)
375
+ console.log(` manifest: palette-plugin.json (updated)`)
376
+ console.log(` schemas: schemas/${method}.in.json, schemas/${method}.out.json`)
377
+ console.log(` handler: ${path.relative(cwd, handlerPath)}`)
378
+ console.log(` import the handler from your backend entry to register it.`)
379
+ }
380
+
381
+ // ---- add consumed target -----------------------------------------------------
382
+
383
+ async function runAdd(argv, { cwd }) {
384
+ const target = argv.find((arg) => !arg.startsWith("-"))
385
+ if (!target || !brokerTargetLooksValid(target)) {
386
+ console.error("[pltt] usage: pltt services add <namespace/version#name> [--event] [--optional] [--reason <text>]")
387
+ process.exit(1)
388
+ }
389
+ const manifest = readManifest(cwd)
390
+ manifest.consumes = manifest.consumes || {}
391
+ const bucket = argv.includes("--event") || valueAfter(argv, "--type") === "event" ? "events" : "services"
392
+ manifest.consumes[bucket] = manifest.consumes[bucket] || []
393
+ const existing = new Set(manifest.consumes[bucket].map(targetFromConsumeEntry).filter(Boolean))
394
+ if (!existing.has(target)) {
395
+ const reason = valueAfter(argv, "--reason")
396
+ const optional = argv.includes("--optional")
397
+ const entry = reason || optional ? { target, required: !optional, ...(reason ? { reason } : {}) } : target
398
+ manifest.consumes[bucket].push(entry)
399
+ writeManifest(cwd, manifest)
400
+ console.log(`[pltt] added ${target} to consumes.${bucket}`)
401
+ } else {
402
+ console.log(`[pltt] ${target} is already declared in consumes.${bucket}`)
403
+ }
404
+ }
405
+
406
+ // ---- dispatch ----------------------------------------------------------------
407
+
408
+ async function run(argv, ctx) {
409
+ const sub = argv[0]
410
+ const rest = argv.slice(1)
411
+ if (sub === "list") return runList(rest, ctx)
412
+ if (sub === "pull") return runPull(rest, ctx)
413
+ if (sub === "scaffold") return runScaffold(rest, ctx)
414
+ if (sub === "add") return runAdd(rest, ctx)
415
+ console.log("pltt services <command>\n")
416
+ console.log(" list Show services/events available to the current org.")
417
+ console.log(" pull Generate typed TS+Python clients from consumes block.")
418
+ console.log(" scaffold Add a new provider method + schemas + handler stub.")
419
+ console.log(" add Add a consumed service/event target to palette-plugin.json.")
420
+ if (sub && !["help", "--help", "-h"].includes(sub)) {
421
+ console.error(`\n[pltt] unknown subcommand: ${sub}`)
422
+ process.exit(1)
423
+ }
424
+ }
425
+
426
+ module.exports = run
@@ -5,7 +5,7 @@ const path = require("path")
5
5
  const { spawnSync } = require("child_process")
6
6
  const { loadManifest, validateManifest, KNOWN_PERMISSIONS } = require("../manifest")
7
7
  const { bundleFrontend, bundleBackend } = require("../bundler")
8
- const { declaredSecrets, loadLocalEnv } = require("../secrets")
8
+ const { declaredSecrets, loadLocalEnv, loadLocalEnvDetails } = require("../secrets")
9
9
  const buildCommand = require("./build")
10
10
 
11
11
  const DEFAULT_FRONTEND_BUNDLE_LIMIT = 15 * 1024 * 1024
@@ -664,31 +664,32 @@ function checkDeclaredSecrets(cwd, manifest, out) {
664
664
  out.ok("no manifest secrets declared")
665
665
  return 0
666
666
  }
667
- const localValues = loadLocalEnv(cwd, { apply: false })
667
+ const localEnv = loadLocalEnvDetails(cwd, { apply: false })
668
+ const localValues = localEnv.values
668
669
  let failures = 0
669
670
  for (const [name, spec] of Object.entries(declared)) {
670
671
  if (spec.scope.includes("dev") && spec.required && !localValues[name]) {
671
672
  out.warn(
672
- `required dev secret ${name} is missing from .palette/.env.local`,
673
- "Run pltt secrets init and fill the local value before pltt dev.",
673
+ `required dev secret ${name} is missing from local .env files`,
674
+ "Add it to .env, .env.local, or an environment-specific .env file.",
674
675
  { secret: name, scope: spec.scope },
675
676
  )
676
677
  }
677
678
  if (spec.scope.includes("plugin") && spec.required && !localValues[name] && !process.env[name]) {
678
679
  out.warn(
679
680
  `required plugin secret ${name} has no local value`,
680
- "Set it before publish with an env var, --secrets-file, or pltt secrets set.",
681
+ "Add it to .env/.env.local, set an env var, pass --secrets-file, or run pltt secrets set.",
681
682
  { secret: name, scope: spec.scope },
682
683
  )
683
684
  }
684
685
  }
685
- if (!fs.existsSync(path.join(cwd, ".palette", ".env.local"))) {
686
+ if (!localEnv.files.length) {
686
687
  out.warn(
687
- ".palette/.env.local is missing",
688
- "Run pltt secrets init to create local developer env files.",
688
+ "no local .env files found",
689
+ "Create .env or .env.local when your app needs local environment values.",
689
690
  )
690
691
  } else {
691
- out.ok(".palette/.env.local is present")
692
+ out.ok(`local env files present: ${localEnv.files.join(", ")}`)
692
693
  }
693
694
  out.ok(`manifest declares ${names.length} secret(s)`, { secrets: names })
694
695
  return failures
package/lib/manifest.js CHANGED
@@ -53,6 +53,7 @@ const TOP_LEVEL_KEYS = new Set([
53
53
  "platform_services",
54
54
  "provides",
55
55
  "requires",
56
+ "consumes",
56
57
  ])
57
58
 
58
59
  function loadManifest(cwd) {
@@ -237,6 +238,10 @@ function isCapabilityId(value) {
237
238
  return typeof value === "string" && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(value)
238
239
  }
239
240
 
241
+ function isBrokerTarget(value) {
242
+ return typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[A-Za-z0-9_.-]+$/.test(value)
243
+ }
244
+
240
245
  function validateServiceRoutes(value, label, errors) {
241
246
  if (value === undefined) return
242
247
  if (!Array.isArray(value)) {
@@ -262,13 +267,63 @@ function validateServiceRoutes(value, label, errors) {
262
267
  })
263
268
  }
264
269
 
270
+ function validateServiceMethods(value, label, errors) {
271
+ if (value === undefined) return
272
+ if (!Array.isArray(value)) {
273
+ errors.push(`${label}.methods must be an array`)
274
+ return
275
+ }
276
+ const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"])
277
+ const seen = new Set()
278
+ value.forEach((method, i) => {
279
+ const methodLabel = `${label}.methods[${i}]`
280
+ if (!isObject(method)) {
281
+ errors.push(`${methodLabel} must be an object`)
282
+ return
283
+ }
284
+ unknownKeys(
285
+ method,
286
+ new Set([
287
+ "name",
288
+ "scope",
289
+ "label",
290
+ "description",
291
+ "input_schema",
292
+ "input",
293
+ "output_schema",
294
+ "output",
295
+ "route_method",
296
+ "route_path",
297
+ ]),
298
+ methodLabel,
299
+ errors,
300
+ )
301
+ if (typeof method.name !== "string" || !/^[A-Za-z0-9_.-]+$/.test(method.name)) {
302
+ errors.push(`${methodLabel}.name must be a broker method name`)
303
+ } else if (seen.has(method.name)) {
304
+ errors.push(`duplicate provided method name: ${method.name}`)
305
+ }
306
+ seen.add(method.name)
307
+ for (const key of ["scope", "label", "description", "input_schema", "input", "output_schema", "output", "route_path"]) {
308
+ requireString(method, key, methodLabel, errors)
309
+ }
310
+ if (method.route_method !== undefined && !methods.has(String(method.route_method).toUpperCase())) {
311
+ errors.push(`${methodLabel}.route_method must be one of ${Array.from(methods).join(", ")}`)
312
+ }
313
+ if (method.route_path !== undefined && (typeof method.route_path !== "string" || !method.route_path.startsWith("/"))) {
314
+ errors.push(`${methodLabel}.route_path must start with '/'`)
315
+ }
316
+ })
317
+ }
318
+
265
319
  function validateProvides(value, errors) {
266
320
  if (value === undefined) return
267
321
  if (!isObject(value)) {
268
322
  errors.push("provides must be an object")
269
323
  return
270
324
  }
271
- unknownKeys(value, new Set(["services", "events"]), "provides", errors)
325
+ unknownKeys(value, new Set(["namespace", "services", "events"]), "provides", errors)
326
+ requireString(value, "namespace", "provides", errors)
272
327
  if (value.services !== undefined) {
273
328
  if (!Array.isArray(value.services)) {
274
329
  errors.push("provides.services must be an array")
@@ -280,7 +335,7 @@ function validateProvides(value, errors) {
280
335
  errors.push(`${label} must be an object`)
281
336
  return
282
337
  }
283
- unknownKeys(service, new Set(["id", "version", "label", "description", "permissions", "routes"]), label, errors)
338
+ unknownKeys(service, new Set(["id", "version", "label", "description", "permissions", "routes", "methods"]), label, errors)
284
339
  if (!isCapabilityId(service.id)) {
285
340
  errors.push(`${label}.id must be a dotted lowercase capability id`)
286
341
  } else if (seen.has(service.id)) {
@@ -302,6 +357,7 @@ function validateProvides(value, errors) {
302
357
  }
303
358
  }
304
359
  validateServiceRoutes(service.routes, label, errors)
360
+ validateServiceMethods(service.methods, label, errors)
305
361
  })
306
362
  }
307
363
  }
@@ -309,9 +365,29 @@ function validateProvides(value, errors) {
309
365
  if (!Array.isArray(value.events)) {
310
366
  errors.push("provides.events must be an array")
311
367
  } else {
312
- for (const event of value.events) {
313
- if (!isCapabilityId(event)) errors.push(`provides.events entries must be dotted lowercase topics: ${event}`)
314
- }
368
+ const seen = new Set()
369
+ value.events.forEach((event, i) => {
370
+ const label = `provides.events[${i}]`
371
+ let topic
372
+ if (typeof event === "string") {
373
+ topic = event
374
+ } else if (isObject(event)) {
375
+ unknownKeys(event, new Set(["topic", "name", "version", "payload_schema", "schema", "description"]), label, errors)
376
+ topic = event.topic || event.name
377
+ for (const key of ["version", "payload_schema", "schema", "description"]) {
378
+ requireString(event, key, label, errors)
379
+ }
380
+ } else {
381
+ errors.push(`${label} must be a topic string or object`)
382
+ return
383
+ }
384
+ if (!isCapabilityId(topic)) {
385
+ errors.push(`${label}.topic must be a dotted lowercase topic`)
386
+ } else if (seen.has(topic)) {
387
+ errors.push(`duplicate provided event topic: ${topic}`)
388
+ }
389
+ seen.add(topic)
390
+ })
315
391
  }
316
392
  }
317
393
  }
@@ -375,6 +451,44 @@ function validateRequires(value, errors) {
375
451
  }
376
452
  }
377
453
 
454
+ function validateConsumes(value, errors) {
455
+ if (value === undefined) return
456
+ if (!isObject(value)) {
457
+ errors.push("consumes must be an object")
458
+ return
459
+ }
460
+ unknownKeys(value, new Set(["services", "events"]), "consumes", errors)
461
+ for (const bucket of ["services", "events"]) {
462
+ if (value[bucket] === undefined) continue
463
+ if (!Array.isArray(value[bucket])) {
464
+ errors.push(`consumes.${bucket} must be an array`)
465
+ continue
466
+ }
467
+ const seen = new Set()
468
+ value[bucket].forEach((entry, i) => {
469
+ const label = `consumes.${bucket}[${i}]`
470
+ let target
471
+ if (typeof entry === "string") {
472
+ target = entry
473
+ } else if (isObject(entry)) {
474
+ unknownKeys(entry, new Set(["target", "required", "reason"]), label, errors)
475
+ target = entry.target
476
+ requireBoolean(entry, "required", label, errors)
477
+ requireString(entry, "reason", label, errors)
478
+ } else {
479
+ errors.push(`${label} must be a broker target string or object`)
480
+ return
481
+ }
482
+ if (!isBrokerTarget(target)) {
483
+ errors.push(`${label}.target must look like namespace/version#name`)
484
+ return
485
+ }
486
+ if (seen.has(target)) errors.push(`duplicate consumed ${bucket.slice(0, -1)} target: ${target}`)
487
+ seen.add(target)
488
+ })
489
+ }
490
+ }
491
+
378
492
  function validateManifest(m) {
379
493
  const errors = []
380
494
  if (!isObject(m)) return ["manifest must be an object"]
@@ -445,6 +559,7 @@ function validateManifest(m) {
445
559
  validatePlatformServices(m.platform_services, errors)
446
560
  validateProvides(m.provides, errors)
447
561
  validateRequires(m.requires, errors)
562
+ validateConsumes(m.consumes, errors)
448
563
 
449
564
  if (m.sdk) {
450
565
  if (!isObject(m.sdk)) errors.push("sdk must be an object")
package/lib/secrets.js CHANGED
@@ -6,6 +6,22 @@ const path = require("path")
6
6
  const LOCAL_ENV_PATH = path.join(".palette", ".env.local")
7
7
  const EXAMPLE_ENV_PATH = path.join(".palette", ".env.example")
8
8
  const SECRET_SCOPES = new Set(["dev", "plugin", "install", "platform"])
9
+ const RESERVED_AUTO_ENV_KEYS = new Set([
10
+ "CI",
11
+ "HOME",
12
+ "HOST",
13
+ "HOSTNAME",
14
+ "LOGNAME",
15
+ "NODE_ENV",
16
+ "OLDPWD",
17
+ "PATH",
18
+ "PORT",
19
+ "PWD",
20
+ "SHELL",
21
+ "TERM",
22
+ "TMPDIR",
23
+ "USER",
24
+ ])
9
25
 
10
26
  function parseDotEnv(src) {
11
27
  const values = {}
@@ -31,6 +47,50 @@ function readDotEnvFile(filePath) {
31
47
  return parseDotEnv(fs.readFileSync(filePath, "utf8"))
32
48
  }
33
49
 
50
+ function unique(items) {
51
+ return Array.from(new Set(items.filter(Boolean)))
52
+ }
53
+
54
+ function envFileNames(environment) {
55
+ return unique([
56
+ ".env",
57
+ ".env.local",
58
+ environment ? `.env.${environment}` : null,
59
+ environment ? `.env.${environment}.local` : null,
60
+ ])
61
+ }
62
+
63
+ function loadRootEnvFiles(cwd, { environment } = {}) {
64
+ const values = {}
65
+ const files = []
66
+ for (const name of envFileNames(environment || process.env.PALETTE_ENV)) {
67
+ const filePath = path.join(cwd, name)
68
+ if (!fs.existsSync(filePath)) continue
69
+ Object.assign(values, readDotEnvFile(filePath))
70
+ files.push(name)
71
+ }
72
+ return { values, files }
73
+ }
74
+
75
+ function loadLocalEnvDetails(cwd, { apply = true, environment, includePalette = true } = {}) {
76
+ const root = loadRootEnvFiles(cwd, { environment })
77
+ const values = { ...root.values }
78
+ const files = [...root.files]
79
+ if (includePalette) {
80
+ const palettePath = path.join(cwd, LOCAL_ENV_PATH)
81
+ if (fs.existsSync(palettePath)) {
82
+ Object.assign(values, readDotEnvFile(palettePath))
83
+ files.push(LOCAL_ENV_PATH)
84
+ }
85
+ }
86
+ if (apply) {
87
+ for (const [key, value] of Object.entries(values)) {
88
+ if (process.env[key] === undefined) process.env[key] = value
89
+ }
90
+ }
91
+ return { values, files }
92
+ }
93
+
34
94
  function formatDotEnvValue(value) {
35
95
  if (value === undefined || value === null) return ""
36
96
  const str = String(value)
@@ -132,14 +192,25 @@ function initLocalEnv(cwd, manifest, { overwrite = false } = {}) {
132
192
  return { localPath, examplePath, declared }
133
193
  }
134
194
 
135
- function loadLocalEnv(cwd, { apply = true } = {}) {
136
- const values = readDotEnvFile(path.join(cwd, LOCAL_ENV_PATH))
137
- if (apply) {
138
- for (const [key, value] of Object.entries(values)) {
139
- if (process.env[key] === undefined) process.env[key] = value
140
- }
141
- }
142
- return values
195
+ function loadLocalEnv(cwd, { apply = true, environment, includePalette = true } = {}) {
196
+ return loadLocalEnvDetails(cwd, { apply, environment, includePalette }).values
197
+ }
198
+
199
+ function isPublicEnvKey(key) {
200
+ return key.startsWith("NEXT_PUBLIC_")
201
+ }
202
+
203
+ function isReservedAutoEnvKey(key) {
204
+ return (
205
+ RESERVED_AUTO_ENV_KEYS.has(key) ||
206
+ key.startsWith("PALETTE_") ||
207
+ key.startsWith("npm_") ||
208
+ key.startsWith("NPM_")
209
+ )
210
+ }
211
+
212
+ function canAutoUploadEnvKey(key) {
213
+ return /^[A-Z_][A-Z0-9_]*$/.test(key) && !isPublicEnvKey(key) && !isReservedAutoEnvKey(key)
143
214
  }
144
215
 
145
216
  function redactValue(value) {
@@ -156,9 +227,13 @@ module.exports = {
156
227
  declaredSecrets,
157
228
  ensureGitignore,
158
229
  initLocalEnv,
230
+ loadLocalEnvDetails,
159
231
  loadLocalEnv,
160
232
  parseDotEnv,
161
233
  readDotEnvFile,
162
234
  redactValue,
163
235
  secretsForScope,
236
+ canAutoUploadEnvKey,
237
+ isPublicEnvKey,
238
+ isReservedAutoEnvKey,
164
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.48",
3
+ "version": "0.3.49",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"