@palettelab/cli 0.3.30 → 0.3.31
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 +30 -0
- package/backend-sdk/palette_sdk/__init__.py +2 -1
- package/backend-sdk/palette_sdk/manifest.py +18 -1
- package/backend-sdk/palette_sdk/plugin_context.py +13 -0
- package/docs/python-backend-sdk.md +26 -2
- package/lib/cli.js +10 -0
- package/lib/commands/dev.js +2 -0
- package/lib/commands/doctor.js +20 -0
- package/lib/commands/init.js +7 -0
- package/lib/commands/publish.js +54 -0
- package/lib/commands/secrets.js +124 -0
- package/lib/commands/test.js +61 -0
- package/lib/dev-simulator.js +8 -1
- package/lib/environments.js +5 -0
- package/lib/manifest.js +71 -0
- package/lib/secrets.js +155 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -441,6 +441,36 @@ Environment variables:
|
|
|
441
441
|
| `PALETTE_DEV_DATABASE_URL` | `.palette/dev/<plugin-id>.sqlite3` | Override the local dev database URL |
|
|
442
442
|
| `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 |
|
|
443
443
|
|
|
444
|
+
### `pltt secrets`
|
|
445
|
+
|
|
446
|
+
Palette secrets are declared in `palette-plugin.json` and resolved through
|
|
447
|
+
`ctx.secret("NAME")`.
|
|
448
|
+
|
|
449
|
+
```json
|
|
450
|
+
{
|
|
451
|
+
"secrets": {
|
|
452
|
+
"OPENAI_API_KEY": { "scope": "install", "required": true },
|
|
453
|
+
"STRIPE_SECRET": { "scope": "plugin", "required": true },
|
|
454
|
+
"DEBUG_PROBE_URL": { "scope": "dev", "required": false }
|
|
455
|
+
},
|
|
456
|
+
"platform_services": ["llm", "kv", "storage"]
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Commands:
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
pltt secrets init
|
|
464
|
+
pltt secrets set STRIPE_SECRET --env staging --value sk_live_...
|
|
465
|
+
pltt secrets list --env staging
|
|
466
|
+
pltt publish --env staging --secrets-file plugin-secrets.env
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`dev` secrets live in `.palette/.env.local`, are loaded by `pltt dev`, and are
|
|
470
|
+
never uploaded. `plugin` secrets are encrypted by the platform and attached to
|
|
471
|
+
the plugin/environment. `install` secrets are filled by the installing org.
|
|
472
|
+
Frontend bundles may only receive public values such as `NEXT_PUBLIC_*`.
|
|
473
|
+
|
|
444
474
|
### `pltt login`
|
|
445
475
|
|
|
446
476
|
Save a Palette sandbox or production environment URL plus token in `~/.palette/config.json` with file mode `0600`. Environment variables still override the stored token when present.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Palette Platform SDK for building backend plugins."""
|
|
2
2
|
|
|
3
3
|
from palette_sdk.plugin_router import PluginRouter
|
|
4
|
-
from palette_sdk.plugin_context import PluginContext, get_plugin_context
|
|
4
|
+
from palette_sdk.plugin_context import MissingSecretError, PluginContext, get_plugin_context
|
|
5
5
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
6
6
|
from palette_sdk.repository import OrgRepository
|
|
7
7
|
from palette_sdk.lifecycle import LifecycleHooks
|
|
@@ -33,6 +33,7 @@ from palette_sdk.testing import route_permission_issues
|
|
|
33
33
|
__all__ = [
|
|
34
34
|
"PluginRouter",
|
|
35
35
|
"PluginContext",
|
|
36
|
+
"MissingSecretError",
|
|
36
37
|
"get_plugin_context",
|
|
37
38
|
"DataRoomsClient",
|
|
38
39
|
"OrgRepository",
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Literal
|
|
8
8
|
|
|
9
|
-
from pydantic import BaseModel, Field
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class AgentDefinition(BaseModel):
|
|
@@ -73,6 +73,21 @@ class RateLimit(BaseModel):
|
|
|
73
73
|
per_minute: int = 60
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
class SecretSpec(BaseModel):
|
|
77
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
78
|
+
|
|
79
|
+
scope: Literal["dev", "plugin", "install", "platform"] | list[Literal["dev", "plugin", "install", "platform"]] = "dev"
|
|
80
|
+
required: bool = False
|
|
81
|
+
label: str | None = None
|
|
82
|
+
help: str | None = None
|
|
83
|
+
validate_pattern: str | None = Field(default=None, alias="validate")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PlatformServiceSpec(BaseModel):
|
|
87
|
+
required: bool = False
|
|
88
|
+
billing: Literal["org_wallet", "plugin_owner", "platform"] | None = None
|
|
89
|
+
|
|
90
|
+
|
|
76
91
|
class PluginManifest(BaseModel):
|
|
77
92
|
"""Validated plugin manifest from palette-plugin.json."""
|
|
78
93
|
|
|
@@ -97,6 +112,8 @@ class PluginManifest(BaseModel):
|
|
|
97
112
|
agents: list[AgentDefinition] = Field(default_factory=list)
|
|
98
113
|
tools: list[ToolEntry] = Field(default_factory=list)
|
|
99
114
|
permissions: list[str] = Field(default_factory=list)
|
|
115
|
+
secrets: dict[str, SecretSpec] = Field(default_factory=dict)
|
|
116
|
+
platform_services: list[Literal["llm", "kv", "storage"]] | dict[str, PlatformServiceSpec] = Field(default_factory=list)
|
|
100
117
|
rating: float = 0.0
|
|
101
118
|
reviews: int = 0
|
|
102
119
|
featured: bool = False
|
|
@@ -13,6 +13,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
13
13
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
class MissingSecretError(KeyError):
|
|
17
|
+
"""Raised when a declared required plugin secret is not configured."""
|
|
18
|
+
|
|
19
|
+
|
|
16
20
|
@dataclass
|
|
17
21
|
class PluginContext:
|
|
18
22
|
"""Context provided to plugin endpoints by the platform.
|
|
@@ -63,6 +67,15 @@ class PluginContext:
|
|
|
63
67
|
secrets = self.config.get("secrets")
|
|
64
68
|
if isinstance(secrets, dict) and key in secrets:
|
|
65
69
|
return secrets[key]
|
|
70
|
+
specs = self.config.get("secret_specs")
|
|
71
|
+
spec = specs.get(key) if isinstance(specs, dict) else None
|
|
72
|
+
if isinstance(spec, dict):
|
|
73
|
+
if default is not None:
|
|
74
|
+
return default
|
|
75
|
+
if spec.get("required"):
|
|
76
|
+
help_text = spec.get("help") or spec.get("label") or "Configure this secret before using the plugin."
|
|
77
|
+
raise MissingSecretError(f"missing plugin secret {key}: {help_text}")
|
|
78
|
+
return default
|
|
66
79
|
return os.environ.get(key, default)
|
|
67
80
|
|
|
68
81
|
def repo(self, model: type[Any]):
|
|
@@ -478,8 +478,32 @@ async def sync_config(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
478
478
|
}
|
|
479
479
|
```
|
|
480
480
|
|
|
481
|
-
|
|
482
|
-
|
|
481
|
+
Declare secret ownership in `palette-plugin.json`:
|
|
482
|
+
|
|
483
|
+
```json
|
|
484
|
+
{
|
|
485
|
+
"secrets": {
|
|
486
|
+
"FINANCE_API_KEY": {
|
|
487
|
+
"scope": "install",
|
|
488
|
+
"required": true,
|
|
489
|
+
"help": "Provided by the installing organization."
|
|
490
|
+
},
|
|
491
|
+
"AUTHOR_STRIPE_SECRET": {
|
|
492
|
+
"scope": "plugin",
|
|
493
|
+
"required": true
|
|
494
|
+
},
|
|
495
|
+
"DEBUG_PROBE_URL": {
|
|
496
|
+
"scope": "dev",
|
|
497
|
+
"required": false
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
`ctx.secret("KEY")` resolves declared secrets from the configured scope:
|
|
504
|
+
install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
|
|
505
|
+
during `pltt dev`. Undeclared keys still fall back to the process environment
|
|
506
|
+
for local compatibility.
|
|
483
507
|
|
|
484
508
|
## 10. Lifecycle Hooks
|
|
485
509
|
|
package/lib/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ const login = require("./commands/login")
|
|
|
10
10
|
const pkg = require("./commands/package")
|
|
11
11
|
const status = require("./commands/status")
|
|
12
12
|
const logs = require("./commands/logs")
|
|
13
|
+
const secrets = require("./commands/secrets")
|
|
13
14
|
|
|
14
15
|
const COMMANDS = {
|
|
15
16
|
init: { run: init, help: "Scaffold a new plugin directory from the template" },
|
|
@@ -41,6 +42,10 @@ const COMMANDS = {
|
|
|
41
42
|
run: logs,
|
|
42
43
|
help: "Tail telemetry events for a plugin (--follow to stream)",
|
|
43
44
|
},
|
|
45
|
+
secrets: {
|
|
46
|
+
run: secrets,
|
|
47
|
+
help: "Initialize local env files and manage plugin-scope secrets",
|
|
48
|
+
},
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
function printHelp() {
|
|
@@ -68,6 +73,10 @@ function printHelp() {
|
|
|
68
73
|
console.log("\nLogs flags:")
|
|
69
74
|
console.log(" --tail <n> Tail last n events (default 50)")
|
|
70
75
|
console.log(" -f, --follow Stream events (poll every 3s)")
|
|
76
|
+
console.log("\nSecrets:")
|
|
77
|
+
console.log(" pltt secrets init")
|
|
78
|
+
console.log(" pltt secrets set NAME --value <secret> --env staging")
|
|
79
|
+
console.log(" pltt secrets list --env staging")
|
|
71
80
|
console.log("\nExamples:")
|
|
72
81
|
console.log(" pltt init my-app --template database")
|
|
73
82
|
console.log(" pltt login --env staging --url https://sandbox.pltt.ai --token <token>")
|
|
@@ -78,6 +87,7 @@ function printHelp() {
|
|
|
78
87
|
console.log(" pltt publish --env staging")
|
|
79
88
|
console.log(" pltt status")
|
|
80
89
|
console.log(" pltt logs --follow")
|
|
90
|
+
console.log(" pltt secrets init")
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
async function run(argv) {
|
package/lib/commands/dev.js
CHANGED
|
@@ -7,6 +7,7 @@ const { watchFrontend } = require("../bundler")
|
|
|
7
7
|
const { parseFlags } = require("../environments")
|
|
8
8
|
const { resolveDevPorts } = require("../ports")
|
|
9
9
|
const { startSimulator } = require("../dev-simulator")
|
|
10
|
+
const { loadLocalEnv } = require("../secrets")
|
|
10
11
|
const publish = require("./publish")
|
|
11
12
|
|
|
12
13
|
const DEFAULT_PLATFORM_IMAGE = "ghcr.io/palette-lab/platform-dev:latest"
|
|
@@ -78,6 +79,7 @@ async function run(args, { cwd }) {
|
|
|
78
79
|
const { flags, rest } = parseFlags(args)
|
|
79
80
|
const cloud = rest.includes("--cloud") || rest.includes("--sandbox")
|
|
80
81
|
const platform = rest.includes("--platform")
|
|
82
|
+
loadLocalEnv(cwd)
|
|
81
83
|
if (cloud) {
|
|
82
84
|
const json = args.includes("--json")
|
|
83
85
|
const publishArgs = []
|
package/lib/commands/doctor.js
CHANGED
|
@@ -6,6 +6,7 @@ const { spawnSync } = require("child_process")
|
|
|
6
6
|
const { loadManifest, validateManifest } = require("../manifest")
|
|
7
7
|
const { bundleFrontend } = require("../bundler")
|
|
8
8
|
const { resolveDevPorts } = require("../ports")
|
|
9
|
+
const { declaredSecrets, loadLocalEnv } = require("../secrets")
|
|
9
10
|
|
|
10
11
|
const DEFAULT_IMAGE =
|
|
11
12
|
process.env.PALETTE_DEV_IMAGE || "ghcr.io/palette-lab/platform-dev:latest"
|
|
@@ -120,6 +121,25 @@ async function run(args, { cwd }) {
|
|
|
120
121
|
failures += checkEntry(cwd, `tool[${tool.name}]`, tool.entry)
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
const secrets = declaredSecrets(manifest)
|
|
125
|
+
const localSecrets = loadLocalEnv(cwd, { apply: false })
|
|
126
|
+
if (Object.keys(secrets).length === 0) {
|
|
127
|
+
ok("no manifest secrets declared")
|
|
128
|
+
} else {
|
|
129
|
+
ok(`manifest declares ${Object.keys(secrets).length} secret(s)`)
|
|
130
|
+
for (const [name, spec] of Object.entries(secrets)) {
|
|
131
|
+
if (spec.scope.includes("dev") && !localSecrets[name]) {
|
|
132
|
+
warn(`local dev secret missing: ${name}`, "Run pltt secrets init and fill .palette/.env.local.")
|
|
133
|
+
}
|
|
134
|
+
if (spec.scope.includes("plugin") && spec.required && !localSecrets[name] && !process.env[name]) {
|
|
135
|
+
warn(
|
|
136
|
+
`plugin secret missing locally: ${name}`,
|
|
137
|
+
"Set an env var, pass --secrets-file to publish, or run pltt secrets set after first publish.",
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
if (manifest.frontend?.entry) {
|
|
124
144
|
try {
|
|
125
145
|
const bundle = await bundleFrontend(cwd, manifest.frontend.entry, manifest.frontend)
|
package/lib/commands/init.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require("fs")
|
|
|
4
4
|
const path = require("path")
|
|
5
5
|
const { spawnSync } = require("child_process")
|
|
6
6
|
const os = require("os")
|
|
7
|
+
const { initLocalEnv } = require("../secrets")
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TEMPLATE_REPO = "palette-lab/plugin-template"
|
|
9
10
|
const TEMPLATE_REPO = process.env.PALETTE_TEMPLATE_REPO || DEFAULT_TEMPLATE_REPO
|
|
@@ -165,6 +166,12 @@ async function run(args, { cwd }) {
|
|
|
165
166
|
fs.cpSync(tmp, destDir, { recursive: true })
|
|
166
167
|
fs.rmSync(tmp, { recursive: true, force: true })
|
|
167
168
|
rewriteScaffold(destDir, slug, displayName)
|
|
169
|
+
try {
|
|
170
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(destDir, "palette-plugin.json"), "utf8"))
|
|
171
|
+
initLocalEnv(destDir, manifest)
|
|
172
|
+
} catch (_err) {
|
|
173
|
+
// The scaffold is still usable; `pltt secrets init` can repair env files.
|
|
174
|
+
}
|
|
168
175
|
|
|
169
176
|
console.log(`[pltt] created ${destDir}`)
|
|
170
177
|
console.log("[pltt] next steps:")
|
package/lib/commands/publish.js
CHANGED
|
@@ -11,6 +11,13 @@ const {
|
|
|
11
11
|
parseFlags,
|
|
12
12
|
confirmProduction,
|
|
13
13
|
} = require("../environments")
|
|
14
|
+
const {
|
|
15
|
+
declaredSecrets,
|
|
16
|
+
loadLocalEnv,
|
|
17
|
+
parseDotEnv,
|
|
18
|
+
readDotEnvFile,
|
|
19
|
+
redactValue,
|
|
20
|
+
} = require("../secrets")
|
|
14
21
|
|
|
15
22
|
function sha256(buf) {
|
|
16
23
|
return crypto.createHash("sha256").update(buf).digest("hex")
|
|
@@ -77,6 +84,43 @@ async function put(url, buf, contentType) {
|
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
function collectPluginSecrets(cwd, manifest, env, flags, log) {
|
|
88
|
+
const declared = declaredSecrets(manifest)
|
|
89
|
+
const pluginSecrets = Object.entries(declared).filter(([, spec]) => spec.scope.includes("plugin"))
|
|
90
|
+
const devRequired = Object.entries(declared).filter(([, spec]) => spec.scope.includes("dev") && spec.required)
|
|
91
|
+
if (devRequired.length) {
|
|
92
|
+
log(
|
|
93
|
+
`[pltt] dev-only secrets are not uploaded: ${devRequired.map(([name]) => name).join(", ")}`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let fileValues = {}
|
|
98
|
+
if (flags.secretsFile) {
|
|
99
|
+
fileValues = parseDotEnv(fs.readFileSync(path.resolve(cwd, flags.secretsFile), "utf8"))
|
|
100
|
+
}
|
|
101
|
+
const localValues = readDotEnvFile(path.join(cwd, ".palette", ".env.local"))
|
|
102
|
+
const values = {}
|
|
103
|
+
const missing = []
|
|
104
|
+
for (const [name, spec] of pluginSecrets) {
|
|
105
|
+
const value = fileValues[name] ?? process.env[name] ?? localValues[name]
|
|
106
|
+
if (value) {
|
|
107
|
+
values[name] = value
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (spec.required) missing.push(name)
|
|
111
|
+
}
|
|
112
|
+
if (missing.length) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`missing required plugin-scope secret(s): ${missing.join(", ")}. ` +
|
|
115
|
+
`Set env vars, pass --secrets-file, or run pltt secrets set <NAME> --env ${env.name}.`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
for (const [name, value] of Object.entries(values)) {
|
|
119
|
+
log(`[pltt] plugin secret ${name}=${redactValue(value)} (${env.name})`)
|
|
120
|
+
}
|
|
121
|
+
return values
|
|
122
|
+
}
|
|
123
|
+
|
|
80
124
|
async function run(argv, { cwd }) {
|
|
81
125
|
const { flags } = parseFlags(argv)
|
|
82
126
|
const log = (...args) => {
|
|
@@ -109,6 +153,7 @@ async function run(argv, { cwd }) {
|
|
|
109
153
|
}
|
|
110
154
|
|
|
111
155
|
const manifest = loadManifest(cwd)
|
|
156
|
+
loadLocalEnv(cwd)
|
|
112
157
|
const errors = validateManifest(manifest)
|
|
113
158
|
if (errors.length) {
|
|
114
159
|
console.error("[pltt] manifest invalid:")
|
|
@@ -137,6 +182,13 @@ async function run(argv, { cwd }) {
|
|
|
137
182
|
|
|
138
183
|
const backendSha = sha256(backend)
|
|
139
184
|
const api = makeApi(env)
|
|
185
|
+
let pluginSecrets = {}
|
|
186
|
+
try {
|
|
187
|
+
pluginSecrets = collectPluginSecrets(cwd, manifest, env, flags, log)
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error(`[pltt] ${err instanceof Error ? err.message : String(err)}`)
|
|
190
|
+
process.exit(1)
|
|
191
|
+
}
|
|
140
192
|
|
|
141
193
|
log("[pltt] requesting signed URLs")
|
|
142
194
|
const signed = await api("/api/v1/appstore/sign-upload", {
|
|
@@ -169,7 +221,9 @@ async function run(argv, { cwd }) {
|
|
|
169
221
|
bundle_path: signed.bundle_path,
|
|
170
222
|
bundle_sha256: backendSha,
|
|
171
223
|
manifest,
|
|
224
|
+
environment: env.name,
|
|
172
225
|
}
|
|
226
|
+
if (Object.keys(pluginSecrets).length) publishBody.plugin_secrets = pluginSecrets
|
|
173
227
|
if (Number.isFinite(flags.ttlHours) && flags.ttlHours > 0) {
|
|
174
228
|
publishBody.preview_ttl_hours = flags.ttlHours
|
|
175
229
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const path = require("path")
|
|
5
|
+
const { loadManifest } = require("../manifest")
|
|
6
|
+
const { parseFlags, resolveEnvironment } = require("../environments")
|
|
7
|
+
const {
|
|
8
|
+
declaredSecrets,
|
|
9
|
+
initLocalEnv,
|
|
10
|
+
parseDotEnv,
|
|
11
|
+
readDotEnvFile,
|
|
12
|
+
redactValue,
|
|
13
|
+
} = require("../secrets")
|
|
14
|
+
|
|
15
|
+
function parseOwnFlags(argv) {
|
|
16
|
+
const out = { value: undefined, secretsFile: undefined }
|
|
17
|
+
const rest = []
|
|
18
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
19
|
+
const arg = argv[i]
|
|
20
|
+
if (arg === "--value") out.value = argv[++i]
|
|
21
|
+
else if (arg.startsWith("--value=")) out.value = arg.slice("--value=".length)
|
|
22
|
+
else if (arg === "--secrets-file") out.secretsFile = argv[++i]
|
|
23
|
+
else if (arg.startsWith("--secrets-file=")) out.secretsFile = arg.slice("--secrets-file=".length)
|
|
24
|
+
else rest.push(arg)
|
|
25
|
+
}
|
|
26
|
+
return { own: out, rest }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeApi(env) {
|
|
30
|
+
return async function api(pathname, { method = "GET", body } = {}) {
|
|
31
|
+
const res = await fetch(`${env.url}${pathname}`, {
|
|
32
|
+
method,
|
|
33
|
+
headers: {
|
|
34
|
+
Authorization: `Bearer ${env.token}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
},
|
|
37
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
38
|
+
})
|
|
39
|
+
if (!res.ok) throw new Error(`${method} ${pathname} -> ${res.status}: ${await res.text()}`)
|
|
40
|
+
const ct = res.headers.get("content-type") || ""
|
|
41
|
+
return ct.includes("application/json") ? res.json() : res.text()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadFileValues(cwd, file) {
|
|
46
|
+
if (!file) return {}
|
|
47
|
+
const abs = path.resolve(cwd, file)
|
|
48
|
+
return parseDotEnv(fs.readFileSync(abs, "utf8"))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function run(args, { cwd }) {
|
|
52
|
+
const [subcommand, ...tail] = args
|
|
53
|
+
const manifest = loadManifest(cwd)
|
|
54
|
+
const declared = declaredSecrets(manifest)
|
|
55
|
+
|
|
56
|
+
if (!subcommand || subcommand === "help" || subcommand === "--help") {
|
|
57
|
+
console.log("Usage: pltt secrets <init|list|set|rotate> [NAME] [--env staging]")
|
|
58
|
+
console.log(" pltt secrets set NAME --value <secret> --env staging")
|
|
59
|
+
console.log(" pltt secrets set NAME --secrets-file plugin-secrets.env --env staging")
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (subcommand === "init") {
|
|
64
|
+
const result = initLocalEnv(cwd, manifest)
|
|
65
|
+
console.log(`[pltt] wrote ${path.relative(cwd, result.localPath)}`)
|
|
66
|
+
console.log(`[pltt] wrote ${path.relative(cwd, result.examplePath)}`)
|
|
67
|
+
console.log("[pltt] updated .gitignore for local env files")
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { own, rest } = parseOwnFlags(tail)
|
|
72
|
+
const { flags } = parseFlags(rest)
|
|
73
|
+
let env
|
|
74
|
+
try {
|
|
75
|
+
env = resolveEnvironment({ cwd, flags })
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(`[pltt] ${err.message}`)
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
80
|
+
if (!env.token) {
|
|
81
|
+
console.error(`[pltt] no publish token for "${env.name}". Set $${env.token_env}.`)
|
|
82
|
+
process.exit(1)
|
|
83
|
+
}
|
|
84
|
+
const api = makeApi(env)
|
|
85
|
+
|
|
86
|
+
if (subcommand === "list") {
|
|
87
|
+
const response = await api(`/api/v1/appstore/plugins/${encodeURIComponent(manifest.id)}/secrets?env=${encodeURIComponent(env.name)}`)
|
|
88
|
+
for (const item of response.secrets || []) {
|
|
89
|
+
console.log(`${item.key}\tconfigured=${item.configured ? "yes" : "no"}\tupdated=${item.updated_at || "-"}`)
|
|
90
|
+
}
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (subcommand !== "set" && subcommand !== "rotate") {
|
|
95
|
+
console.error(`[pltt] unknown secrets command: ${subcommand}`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const name = rest.find((item) => !item.startsWith("-"))
|
|
100
|
+
if (!name) {
|
|
101
|
+
console.error("[pltt] usage: pltt secrets set NAME --value <secret> [--env staging]")
|
|
102
|
+
process.exit(1)
|
|
103
|
+
}
|
|
104
|
+
const spec = declared[name]
|
|
105
|
+
if (spec && !spec.scope.includes("plugin")) {
|
|
106
|
+
console.error(`[pltt] ${name} is declared with scope ${spec.scope.join(",")}; only plugin-scope secrets are managed by this command.`)
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
const fileValues = loadFileValues(cwd, own.secretsFile)
|
|
110
|
+
const localValues = readDotEnvFile(path.join(cwd, ".palette", ".env.local"))
|
|
111
|
+
const value = own.value ?? fileValues[name] ?? process.env[name] ?? localValues[name]
|
|
112
|
+
if (!value) {
|
|
113
|
+
console.error(`[pltt] no value for ${name}. Pass --value, --secrets-file, or set $${name}.`)
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await api(`/api/v1/appstore/plugins/${encodeURIComponent(manifest.id)}/secrets/${encodeURIComponent(name)}`, {
|
|
118
|
+
method: "PUT",
|
|
119
|
+
body: { value, environment: env.name },
|
|
120
|
+
})
|
|
121
|
+
console.log(`[pltt] ${subcommand === "rotate" ? "rotated" : "set"} ${name}=${redactValue(value)} for ${manifest.id} (${env.name})`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = run
|
package/lib/commands/test.js
CHANGED
|
@@ -5,6 +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
9
|
const buildCommand = require("./build")
|
|
9
10
|
|
|
10
11
|
const DEFAULT_FRONTEND_BUNDLE_LIMIT = 512 * 1024
|
|
@@ -601,6 +602,64 @@ function sandboxBridgeSmoke(cwd, manifest, out) {
|
|
|
601
602
|
return 0
|
|
602
603
|
}
|
|
603
604
|
|
|
605
|
+
function checkDeclaredSecrets(cwd, manifest, out) {
|
|
606
|
+
const declared = declaredSecrets(manifest)
|
|
607
|
+
const names = Object.keys(declared)
|
|
608
|
+
if (names.length === 0) {
|
|
609
|
+
out.ok("no manifest secrets declared")
|
|
610
|
+
return 0
|
|
611
|
+
}
|
|
612
|
+
const localValues = loadLocalEnv(cwd, { apply: false })
|
|
613
|
+
let failures = 0
|
|
614
|
+
for (const [name, spec] of Object.entries(declared)) {
|
|
615
|
+
if (spec.scope.includes("dev") && spec.required && !localValues[name]) {
|
|
616
|
+
out.warn(
|
|
617
|
+
`required dev secret ${name} is missing from .palette/.env.local`,
|
|
618
|
+
"Run pltt secrets init and fill the local value before pltt dev.",
|
|
619
|
+
{ secret: name, scope: spec.scope },
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
if (spec.scope.includes("plugin") && spec.required && !localValues[name] && !process.env[name]) {
|
|
623
|
+
out.warn(
|
|
624
|
+
`required plugin secret ${name} has no local value`,
|
|
625
|
+
"Set it before publish with an env var, --secrets-file, or pltt secrets set.",
|
|
626
|
+
{ secret: name, scope: spec.scope },
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (!fs.existsSync(path.join(cwd, ".palette", ".env.local"))) {
|
|
631
|
+
out.warn(
|
|
632
|
+
".palette/.env.local is missing",
|
|
633
|
+
"Run pltt secrets init to create local developer env files.",
|
|
634
|
+
)
|
|
635
|
+
} else {
|
|
636
|
+
out.ok(".palette/.env.local is present")
|
|
637
|
+
}
|
|
638
|
+
out.ok(`manifest declares ${names.length} secret(s)`, { secrets: names })
|
|
639
|
+
return failures
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function checkFrontendSecretLeaks(cwd, frontendBuffer, manifest, out) {
|
|
643
|
+
const localValues = loadLocalEnv(cwd, { apply: false })
|
|
644
|
+
const declared = declaredSecrets(manifest)
|
|
645
|
+
let failures = 0
|
|
646
|
+
const bundleText = frontendBuffer.toString("utf8")
|
|
647
|
+
for (const [name, value] of Object.entries(localValues)) {
|
|
648
|
+
if (!value || String(value).length < 8) continue
|
|
649
|
+
const spec = declared[name]
|
|
650
|
+
const publicAllowed = name.startsWith("NEXT_PUBLIC_")
|
|
651
|
+
if (!publicAllowed && bundleText.includes(String(value))) {
|
|
652
|
+
failures += out.fail(
|
|
653
|
+
`frontend bundle contains local secret value for ${name}`,
|
|
654
|
+
"Move this value to backend ctx.secret(...) or rename it NEXT_PUBLIC_* only if it is intentionally public.",
|
|
655
|
+
{ secret: name, declared_scope: spec?.scope },
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (failures === 0) out.ok("frontend local-secret leak scan passed")
|
|
660
|
+
return failures
|
|
661
|
+
}
|
|
662
|
+
|
|
604
663
|
async function run(args, { cwd }) {
|
|
605
664
|
const json = args.includes("--json")
|
|
606
665
|
let failures = 0
|
|
@@ -630,6 +689,7 @@ async function run(args, { cwd }) {
|
|
|
630
689
|
failures += checkSdkCompatibility(cwd, manifest, out)
|
|
631
690
|
failures += scanForbiddenImports(cwd, manifest, out)
|
|
632
691
|
failures += checkSemverBump(cwd, manifest, out)
|
|
692
|
+
failures += checkDeclaredSecrets(cwd, manifest, out)
|
|
633
693
|
|
|
634
694
|
for (const permission of manifest.permissions || []) {
|
|
635
695
|
if (!KNOWN_PERMISSIONS.has(permission)) {
|
|
@@ -662,6 +722,7 @@ async function run(args, { cwd }) {
|
|
|
662
722
|
const frontend = await bundleFrontend(cwd, manifest.frontend.entry, manifest.frontend)
|
|
663
723
|
out.ok(`frontend bundles successfully (${frontend.length} bytes)`, { bytes: frontend.length })
|
|
664
724
|
failures += checkBundleSize("frontend", frontend.length, out)
|
|
725
|
+
failures += checkFrontendSecretLeaks(cwd, frontend, manifest, out)
|
|
665
726
|
failures += sandboxBridgeSmoke(cwd, manifest, out)
|
|
666
727
|
} catch (err) {
|
|
667
728
|
failures += out.fail(
|
package/lib/dev-simulator.js
CHANGED
|
@@ -7,6 +7,7 @@ const { spawn, spawnSync } = require("child_process")
|
|
|
7
7
|
|
|
8
8
|
const { loadManifest } = require("./manifest")
|
|
9
9
|
const { frontendBuildConfig } = require("./bundler")
|
|
10
|
+
const { loadLocalEnv } = require("./secrets")
|
|
10
11
|
|
|
11
12
|
function loadEsbuild() {
|
|
12
13
|
try {
|
|
@@ -95,6 +96,7 @@ function writeBackendRunner(cwd, devDir, manifest, backendEntry) {
|
|
|
95
96
|
const runner = path.join(devDir, "backend_runner.py")
|
|
96
97
|
const sdkPath = localBackendSdkPath()
|
|
97
98
|
const databasePath = path.join(devDir, `${manifest.id}.sqlite3`)
|
|
99
|
+
const devSecrets = loadLocalEnv(cwd, { apply: false })
|
|
98
100
|
const content = `from __future__ import annotations
|
|
99
101
|
|
|
100
102
|
import importlib
|
|
@@ -115,6 +117,7 @@ MANIFEST = json.loads(${JSON.stringify(JSON.stringify(manifest))})
|
|
|
115
117
|
SDK_PATH = ${JSON.stringify(sdkPath || "")}
|
|
116
118
|
DATABASE_ENABLED = bool(MANIFEST.get("database") or MANIFEST.get("capabilities", {}).get("database"))
|
|
117
119
|
DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///${databasePath.replace(/\\/g, "/")}")
|
|
120
|
+
DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
|
|
118
121
|
|
|
119
122
|
if SDK_PATH:
|
|
120
123
|
sys.path.insert(0, SDK_PATH)
|
|
@@ -153,7 +156,11 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
|
|
|
153
156
|
request.state.org_role = "owner"
|
|
154
157
|
request.state.plugin_id = MANIFEST.get("id", "")
|
|
155
158
|
request.state.plugin_permissions = MANIFEST.get("permissions", [])
|
|
156
|
-
request.state.plugin_config = {
|
|
159
|
+
request.state.plugin_config = {
|
|
160
|
+
"secrets": DEV_SECRETS,
|
|
161
|
+
"secret_specs": MANIFEST.get("secrets") or {},
|
|
162
|
+
"secret_scope": "dev",
|
|
163
|
+
}
|
|
157
164
|
request.state.storage = None
|
|
158
165
|
if SessionLocal is None:
|
|
159
166
|
request.state.db = None
|
package/lib/environments.js
CHANGED
|
@@ -173,6 +173,7 @@ function parseFlags(argv) {
|
|
|
173
173
|
url: undefined,
|
|
174
174
|
token: undefined,
|
|
175
175
|
default: true,
|
|
176
|
+
secretsFile: undefined,
|
|
176
177
|
}
|
|
177
178
|
const rest = []
|
|
178
179
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -201,6 +202,10 @@ function parseFlags(argv) {
|
|
|
201
202
|
flags.token = a.slice("--token=".length)
|
|
202
203
|
} else if (a === "--no-default") {
|
|
203
204
|
flags.default = false
|
|
205
|
+
} else if (a === "--secrets-file") {
|
|
206
|
+
flags.secretsFile = argv[++i]
|
|
207
|
+
} else if (a.startsWith("--secrets-file=")) {
|
|
208
|
+
flags.secretsFile = a.slice("--secrets-file=".length)
|
|
204
209
|
} else {
|
|
205
210
|
rest.push(a)
|
|
206
211
|
}
|
package/lib/manifest.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require("fs")
|
|
4
4
|
const path = require("path")
|
|
5
|
+
const { SECRET_SCOPES } = require("./secrets")
|
|
5
6
|
|
|
6
7
|
const MANIFEST_FILE = "palette-plugin.json"
|
|
7
8
|
const SUPPORTED_MANIFEST_VERSIONS = ["1"]
|
|
@@ -46,6 +47,8 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
46
47
|
"rate_limit",
|
|
47
48
|
"database",
|
|
48
49
|
"scheduled_jobs",
|
|
50
|
+
"secrets",
|
|
51
|
+
"platform_services",
|
|
49
52
|
])
|
|
50
53
|
|
|
51
54
|
function loadManifest(cwd) {
|
|
@@ -104,6 +107,71 @@ function validateArray(value, label, errors) {
|
|
|
104
107
|
if (value !== undefined && !Array.isArray(value)) errors.push(`${label} must be an array`)
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
function validateSecrets(value, errors) {
|
|
111
|
+
if (value === undefined) return
|
|
112
|
+
if (!isObject(value)) {
|
|
113
|
+
errors.push("secrets must be an object")
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const allowed = new Set(["scope", "required", "label", "help", "validate"])
|
|
117
|
+
for (const [name, spec] of Object.entries(value)) {
|
|
118
|
+
const label = `secrets.${name}`
|
|
119
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) {
|
|
120
|
+
errors.push(`${label} must be an uppercase environment-style name`)
|
|
121
|
+
}
|
|
122
|
+
if (!isObject(spec)) {
|
|
123
|
+
errors.push(`${label} must be an object`)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
unknownKeys(spec, allowed, label, errors)
|
|
127
|
+
const scopes = Array.isArray(spec.scope) ? spec.scope : [spec.scope || "dev"]
|
|
128
|
+
for (const scope of scopes) {
|
|
129
|
+
if (!SECRET_SCOPES.has(scope)) {
|
|
130
|
+
errors.push(`${label}.scope must be one of ${Array.from(SECRET_SCOPES).join(", ")}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
requireBoolean(spec, "required", label, errors)
|
|
134
|
+
requireString(spec, "label", label, errors)
|
|
135
|
+
requireString(spec, "help", label, errors)
|
|
136
|
+
requireString(spec, "validate", label, errors)
|
|
137
|
+
if (spec.validate !== undefined) {
|
|
138
|
+
try {
|
|
139
|
+
new RegExp(spec.validate)
|
|
140
|
+
} catch (err) {
|
|
141
|
+
errors.push(`${label}.validate must be a valid regular expression`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validatePlatformServices(value, errors) {
|
|
148
|
+
if (value === undefined) return
|
|
149
|
+
const known = new Set(["llm", "kv", "storage"])
|
|
150
|
+
if (Array.isArray(value)) {
|
|
151
|
+
for (const service of value) {
|
|
152
|
+
if (!known.has(service)) errors.push(`platform_services contains unknown service: ${service}`)
|
|
153
|
+
}
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (!isObject(value)) {
|
|
157
|
+
errors.push("platform_services must be an array or object")
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
for (const [name, spec] of Object.entries(value)) {
|
|
161
|
+
if (!known.has(name)) errors.push(`platform_services.${name} is not supported`)
|
|
162
|
+
if (!isObject(spec)) {
|
|
163
|
+
errors.push(`platform_services.${name} must be an object`)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
unknownKeys(spec, new Set(["required", "billing"]), `platform_services.${name}`, errors)
|
|
167
|
+
requireBoolean(spec, "required", `platform_services.${name}`, errors)
|
|
168
|
+
requireString(spec, "billing", `platform_services.${name}`, errors)
|
|
169
|
+
if (spec.billing !== undefined && !["org_wallet", "plugin_owner", "platform"].includes(spec.billing)) {
|
|
170
|
+
errors.push(`platform_services.${name}.billing must be org_wallet, plugin_owner, or platform`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
107
175
|
function validateManifest(m) {
|
|
108
176
|
const errors = []
|
|
109
177
|
if (!isObject(m)) return ["manifest must be an object"]
|
|
@@ -169,6 +237,9 @@ function validateManifest(m) {
|
|
|
169
237
|
errors.push("at least one of frontend, backend, or tools is required")
|
|
170
238
|
}
|
|
171
239
|
|
|
240
|
+
validateSecrets(m.secrets, errors)
|
|
241
|
+
validatePlatformServices(m.platform_services, errors)
|
|
242
|
+
|
|
172
243
|
if (m.sdk) {
|
|
173
244
|
if (!isObject(m.sdk)) errors.push("sdk must be an object")
|
|
174
245
|
else {
|
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const path = require("path")
|
|
5
|
+
|
|
6
|
+
const LOCAL_ENV_PATH = path.join(".palette", ".env.local")
|
|
7
|
+
const EXAMPLE_ENV_PATH = path.join(".palette", ".env.example")
|
|
8
|
+
const SECRET_SCOPES = new Set(["dev", "plugin", "install", "platform"])
|
|
9
|
+
|
|
10
|
+
function parseDotEnv(src) {
|
|
11
|
+
const values = {}
|
|
12
|
+
for (const rawLine of String(src || "").split(/\r?\n/)) {
|
|
13
|
+
const line = rawLine.trim()
|
|
14
|
+
if (!line || line.startsWith("#")) continue
|
|
15
|
+
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
|
|
16
|
+
if (!match) continue
|
|
17
|
+
let value = match[2] || ""
|
|
18
|
+
if (
|
|
19
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
20
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
21
|
+
) {
|
|
22
|
+
value = value.slice(1, -1)
|
|
23
|
+
}
|
|
24
|
+
values[match[1]] = value
|
|
25
|
+
}
|
|
26
|
+
return values
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readDotEnvFile(filePath) {
|
|
30
|
+
if (!fs.existsSync(filePath)) return {}
|
|
31
|
+
return parseDotEnv(fs.readFileSync(filePath, "utf8"))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeDotEnvFile(filePath, values, manifest) {
|
|
35
|
+
const lines = [
|
|
36
|
+
"# Palette local developer secrets.",
|
|
37
|
+
"# This file is for pltt dev only and must not be committed.",
|
|
38
|
+
"",
|
|
39
|
+
]
|
|
40
|
+
for (const [name, meta] of Object.entries(values)) {
|
|
41
|
+
const scope = Array.isArray(meta.scope) ? meta.scope.join(",") : meta.scope
|
|
42
|
+
lines.push(`# ${name}`)
|
|
43
|
+
lines.push(`# scope: ${scope || "dev"}`)
|
|
44
|
+
if (meta.label) lines.push(`# label: ${meta.label}`)
|
|
45
|
+
if (meta.help) lines.push(`# help: ${meta.help}`)
|
|
46
|
+
lines.push(`${name}=`)
|
|
47
|
+
lines.push("")
|
|
48
|
+
}
|
|
49
|
+
if (Object.keys(values).length === 0 && manifest?.id) {
|
|
50
|
+
lines.push(`# No secrets are declared by ${manifest.id}.`)
|
|
51
|
+
lines.push("")
|
|
52
|
+
}
|
|
53
|
+
fs.writeFileSync(filePath, lines.join("\n"))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ensurePaletteDir(cwd) {
|
|
57
|
+
const dir = path.join(cwd, ".palette")
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
59
|
+
return dir
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureGitignore(cwd) {
|
|
63
|
+
const gitignorePath = path.join(cwd, ".gitignore")
|
|
64
|
+
const required = [
|
|
65
|
+
".palette/.env.local",
|
|
66
|
+
".palette/*.env",
|
|
67
|
+
".env",
|
|
68
|
+
".env.local",
|
|
69
|
+
".env.production",
|
|
70
|
+
]
|
|
71
|
+
let existing = ""
|
|
72
|
+
if (fs.existsSync(gitignorePath)) existing = fs.readFileSync(gitignorePath, "utf8")
|
|
73
|
+
const lines = existing ? existing.split(/\r?\n/) : []
|
|
74
|
+
let changed = false
|
|
75
|
+
for (const entry of required) {
|
|
76
|
+
if (!lines.includes(entry)) {
|
|
77
|
+
lines.push(entry)
|
|
78
|
+
changed = true
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (changed || !fs.existsSync(gitignorePath)) {
|
|
82
|
+
fs.writeFileSync(gitignorePath, lines.filter((line, index) => line || index < lines.length - 1).join("\n") + "\n")
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeScope(scope) {
|
|
87
|
+
if (Array.isArray(scope)) return scope
|
|
88
|
+
if (typeof scope === "string") return [scope]
|
|
89
|
+
return ["dev"]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function declaredSecrets(manifest) {
|
|
93
|
+
const raw = manifest?.secrets || {}
|
|
94
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}
|
|
95
|
+
const out = {}
|
|
96
|
+
for (const [name, meta] of Object.entries(raw)) {
|
|
97
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) continue
|
|
98
|
+
const item = meta && typeof meta === "object" && !Array.isArray(meta) ? meta : {}
|
|
99
|
+
out[name] = {
|
|
100
|
+
...item,
|
|
101
|
+
scope: normalizeScope(item.scope).filter((scope) => SECRET_SCOPES.has(scope)),
|
|
102
|
+
required: Boolean(item.required),
|
|
103
|
+
}
|
|
104
|
+
if (out[name].scope.length === 0) out[name].scope = ["dev"]
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function secretsForScope(manifest, scope) {
|
|
110
|
+
return Object.fromEntries(
|
|
111
|
+
Object.entries(declaredSecrets(manifest)).filter(([, meta]) => meta.scope.includes(scope)),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function initLocalEnv(cwd, manifest, { overwrite = false } = {}) {
|
|
116
|
+
ensurePaletteDir(cwd)
|
|
117
|
+
ensureGitignore(cwd)
|
|
118
|
+
const declared = declaredSecrets(manifest)
|
|
119
|
+
const localPath = path.join(cwd, LOCAL_ENV_PATH)
|
|
120
|
+
const examplePath = path.join(cwd, EXAMPLE_ENV_PATH)
|
|
121
|
+
if (overwrite || !fs.existsSync(localPath)) writeDotEnvFile(localPath, declared, manifest)
|
|
122
|
+
writeDotEnvFile(examplePath, declared, manifest)
|
|
123
|
+
return { localPath, examplePath, declared }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function loadLocalEnv(cwd, { apply = true } = {}) {
|
|
127
|
+
const values = readDotEnvFile(path.join(cwd, LOCAL_ENV_PATH))
|
|
128
|
+
if (apply) {
|
|
129
|
+
for (const [key, value] of Object.entries(values)) {
|
|
130
|
+
if (process.env[key] === undefined) process.env[key] = value
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return values
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function redactValue(value) {
|
|
137
|
+
if (!value) return ""
|
|
138
|
+
const str = String(value)
|
|
139
|
+
if (str.length <= 8) return "********"
|
|
140
|
+
return `${str.slice(0, 3)}…${str.slice(-3)}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
EXAMPLE_ENV_PATH,
|
|
145
|
+
LOCAL_ENV_PATH,
|
|
146
|
+
SECRET_SCOPES,
|
|
147
|
+
declaredSecrets,
|
|
148
|
+
ensureGitignore,
|
|
149
|
+
initLocalEnv,
|
|
150
|
+
loadLocalEnv,
|
|
151
|
+
parseDotEnv,
|
|
152
|
+
readDotEnvFile,
|
|
153
|
+
redactValue,
|
|
154
|
+
secretsForScope,
|
|
155
|
+
}
|