@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 +32 -8
- package/docs/python-backend-sdk.md +4 -3
- package/lib/bundler.js +8 -1
- package/lib/cli.js +10 -0
- package/lib/commands/doctor.js +5 -4
- package/lib/commands/publish.js +93 -18
- package/lib/commands/services.js +426 -0
- package/lib/commands/test.js +10 -9
- package/lib/manifest.js +120 -5
- package/lib/secrets.js +83 -8
- package/package.json +1 -1
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
|
-
### `
|
|
536
|
+
### `.env` and secrets
|
|
537
537
|
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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 `.
|
|
635
|
-
during `pltt dev`.
|
|
636
|
-
|
|
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")
|
package/lib/commands/doctor.js
CHANGED
|
@@ -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,
|
|
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
|
|
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}`, "
|
|
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
|
-
"
|
|
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
|
}
|
package/lib/commands/publish.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
package/lib/commands/test.js
CHANGED
|
@@ -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
|
|
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 .
|
|
673
|
-
"
|
|
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
|
-
"
|
|
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 (!
|
|
686
|
+
if (!localEnv.files.length) {
|
|
686
687
|
out.warn(
|
|
687
|
-
".
|
|
688
|
-
"
|
|
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(
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
}
|