@palettelab/cli 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +11 -7
  2. package/bin/{palette.js → pltt.js} +1 -1
  3. package/lib/bundler.js +73 -4
  4. package/lib/cli.js +37 -12
  5. package/lib/commands/build.js +2 -0
  6. package/lib/commands/dev.js +37 -2
  7. package/lib/commands/doctor.js +143 -0
  8. package/lib/commands/init.js +45 -13
  9. package/lib/commands/logs.js +99 -0
  10. package/lib/commands/package.js +64 -0
  11. package/lib/commands/publish.js +50 -6
  12. package/lib/commands/status.js +80 -0
  13. package/lib/commands/test.js +376 -0
  14. package/lib/environments.js +1 -1
  15. package/lib/manifest.js +253 -8
  16. package/package.json +7 -6
  17. package/platform-dev/docker-compose.yml +4 -1
  18. package/template-fallback/backend/api/main.py +9 -3
  19. package/template-fallback/palette-plugin.json +24 -1
  20. package/template-fallback/pyproject.toml +1 -1
  21. package/template-fallback/templates/agent-tool/README.md +4 -0
  22. package/template-fallback/templates/agent-tool/backend/api/main.py +14 -0
  23. package/template-fallback/templates/agent-tool/backend/tools/echo.py +15 -0
  24. package/template-fallback/templates/agent-tool/package.json +5 -0
  25. package/template-fallback/templates/agent-tool/palette-plugin.json +29 -0
  26. package/template-fallback/templates/agent-tool/pyproject.toml +5 -0
  27. package/template-fallback/templates/dashboard/README.md +3 -0
  28. package/template-fallback/templates/dashboard/backend/api/main.py +23 -0
  29. package/template-fallback/templates/dashboard/frontend/src/index.tsx +46 -0
  30. package/template-fallback/templates/dashboard/package.json +9 -0
  31. package/template-fallback/templates/dashboard/palette-plugin.json +26 -0
  32. package/template-fallback/templates/dashboard/pyproject.toml +5 -0
  33. package/template-fallback/templates/database/README.md +7 -0
  34. package/template-fallback/templates/database/backend/api/main.py +38 -0
  35. package/template-fallback/templates/database/backend/api/models.py +11 -0
  36. package/template-fallback/templates/database/backend/migrations/001_init.py +26 -0
  37. package/template-fallback/templates/database/frontend/src/index.tsx +57 -0
  38. package/template-fallback/templates/database/package.json +6 -0
  39. package/template-fallback/templates/database/palette-plugin.json +26 -0
  40. package/template-fallback/templates/database/pyproject.toml +5 -0
  41. package/template-fallback/templates/external-service/README.md +4 -0
  42. package/template-fallback/templates/external-service/backend/api/main.py +28 -0
  43. package/template-fallback/templates/external-service/frontend/src/index.tsx +26 -0
  44. package/template-fallback/templates/external-service/package.json +6 -0
  45. package/template-fallback/templates/external-service/palette-plugin.json +26 -0
  46. package/template-fallback/templates/external-service/pyproject.toml +5 -0
  47. package/template-fallback/templates/frontend-only/README.md +7 -0
  48. package/template-fallback/templates/frontend-only/frontend/src/index.tsx +16 -0
  49. package/template-fallback/templates/frontend-only/package.json +9 -0
  50. package/template-fallback/templates/frontend-only/palette-plugin.json +25 -0
@@ -0,0 +1,376 @@
1
+ "use strict"
2
+
3
+ const fs = require("fs")
4
+ const path = require("path")
5
+ const { spawnSync } = require("child_process")
6
+ const { loadManifest, validateManifest, KNOWN_PERMISSIONS } = require("../manifest")
7
+ const { bundleFrontend, bundleBackend } = require("../bundler")
8
+ const buildCommand = require("./build")
9
+
10
+ function reporter(json, results) {
11
+ return {
12
+ ok(message, meta = {}) {
13
+ results.push({ status: "ok", message, ...meta })
14
+ if (!json) console.log(`OK ${message}`)
15
+ },
16
+ warn(message, fix, meta = {}) {
17
+ results.push({ status: "warn", message, fix, ...meta })
18
+ if (!json) {
19
+ console.log(`WARN ${message}`)
20
+ if (fix) console.log(`FIX ${fix}`)
21
+ }
22
+ return 0
23
+ },
24
+ fail(message, fix, meta = {}) {
25
+ results.push({ status: "fail", message, fix, ...meta })
26
+ if (!json) {
27
+ console.log(`FAIL ${message}`)
28
+ if (fix) console.log(`FIX ${fix}`)
29
+ }
30
+ return 1
31
+ },
32
+ }
33
+ }
34
+
35
+ function backendPython(cwd) {
36
+ return (
37
+ process.env.PALETTE_PYTHON ||
38
+ (fs.existsSync(path.join(cwd, ".venv", "bin", "python"))
39
+ ? path.join(cwd, ".venv", "bin", "python")
40
+ : "python3")
41
+ )
42
+ }
43
+
44
+ function checkBackendContract(cwd, manifest, out) {
45
+ const entry = manifest.backend?.entry
46
+ if (!entry) return 0
47
+ const abs = path.resolve(cwd, entry)
48
+ const python = backendPython(cwd)
49
+ const script = [
50
+ "import importlib.util, json, pathlib, sys",
51
+ "entry = pathlib.Path(sys.argv[1]).resolve()",
52
+ "manifest = json.loads(sys.argv[2])",
53
+ "sys.path.insert(0, str(entry.parent))",
54
+ "spec = importlib.util.spec_from_file_location('palette_contract_backend', entry)",
55
+ "mod = importlib.util.module_from_spec(spec)",
56
+ "spec.loader.exec_module(mod)",
57
+ "router = getattr(mod, 'router', None)",
58
+ "assert router is not None, 'backend entry has no router export'",
59
+ "from palette_sdk.permissions import is_permission_dep",
60
+ "public = set(manifest.get('public_routes') or [])",
61
+ "declared = set(manifest.get('permissions') or [])",
62
+ "routes = []",
63
+ "used = set()",
64
+ "ungated = []",
65
+ "for route in getattr(router, 'routes', []):",
66
+ " path = getattr(route, 'path', None)",
67
+ " if path is None:",
68
+ " continue",
69
+ " methods = sorted(getattr(route, 'methods', []) or [])",
70
+ " dependant = getattr(route, 'dependant', None)",
71
+ " perms = []",
72
+ " stack = [dependant] if dependant is not None else []",
73
+ " seen = set()",
74
+ " while stack:",
75
+ " dep = stack.pop()",
76
+ " if id(dep) in seen:",
77
+ " continue",
78
+ " seen.add(id(dep))",
79
+ " perm = is_permission_dep(getattr(dep, 'call', None))",
80
+ " if perm:",
81
+ " perms.append(perm)",
82
+ " used.add(perm)",
83
+ " stack.extend(getattr(dep, 'dependencies', []) or [])",
84
+ " routes.append({'path': path, 'methods': methods, 'permissions': sorted(set(perms)), 'public': path in public})",
85
+ " if path not in public and not perms:",
86
+ " ungated.append({'path': path, 'methods': methods})",
87
+ "print(json.dumps({'routes': routes, 'used_permissions': sorted(used), 'ungated_routes': ungated, 'undeclared_permissions': sorted(used - declared), 'unused_declared_permissions': sorted(declared - used)}))",
88
+ ].join("\n")
89
+ const res = spawnSync(python, ["-c", script, abs, JSON.stringify(manifest)], { encoding: "utf8" })
90
+ if (res.status === 0) {
91
+ let report
92
+ try {
93
+ report = JSON.parse((res.stdout || "").trim())
94
+ } catch (err) {
95
+ return out.fail(
96
+ `backend contract check produced invalid JSON: ${entry}`,
97
+ "Fix backend import side effects that write to stdout during import.",
98
+ { stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() },
99
+ )
100
+ }
101
+
102
+ let failures = 0
103
+ out.ok(`backend imports successfully: ${entry}`)
104
+
105
+ if (report.ungated_routes?.length) {
106
+ for (const route of report.ungated_routes) {
107
+ failures += out.fail(
108
+ `backend route ${route.path} has no require_permission()`,
109
+ `Add dependencies=[require_permission("resource:action")] or list "${route.path}" in manifest.public_routes if it is intentionally public.`,
110
+ { route },
111
+ )
112
+ }
113
+ } else {
114
+ out.ok("backend route permission gate check passed", { routes: report.routes })
115
+ }
116
+
117
+ if (report.undeclared_permissions?.length) {
118
+ for (const permission of report.undeclared_permissions) {
119
+ failures += out.fail(
120
+ `route uses undeclared permission: ${permission}`,
121
+ `Add "${permission}" to manifest.permissions or change the route dependency.`,
122
+ )
123
+ }
124
+ } else {
125
+ out.ok("route permissions are declared in manifest")
126
+ }
127
+
128
+ if (report.unused_declared_permissions?.length) {
129
+ out.warn(
130
+ `manifest declares unused backend permissions: ${report.unused_declared_permissions.join(", ")}`,
131
+ "Remove unused permissions unless the frontend uses them directly through the SDK.",
132
+ { permissions: report.unused_declared_permissions },
133
+ )
134
+ }
135
+ return failures
136
+ }
137
+ return out.fail(
138
+ `backend import failed: ${entry}`,
139
+ "Install backend dependencies and fix import-time errors before publishing.",
140
+ { stderr: (res.stderr || res.stdout || "").trim() },
141
+ )
142
+ }
143
+
144
+ function lintMigrations(cwd, manifest, out) {
145
+ if (!manifest.database) return 0
146
+ const migrationsRel = manifest.database.migrations || "./backend/migrations"
147
+ const migrationsAbs = path.resolve(cwd, migrationsRel)
148
+ if (!fs.existsSync(migrationsAbs)) {
149
+ return out.fail(`database.migrations directory not found: ${migrationsRel}`)
150
+ }
151
+ if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
152
+ return out.fail(`database.migrations directory is missing env.py: ${migrationsRel}`)
153
+ }
154
+ const errors = buildCommand.lintMigrationsDir(migrationsAbs)
155
+ for (const err of errors) out.fail(err)
156
+ if (errors.length === 0) out.ok("migration lint passed")
157
+ return errors.length
158
+ }
159
+
160
+ function dependencySpecRisk(spec) {
161
+ const value = String(spec || "").trim()
162
+ if (!value || value === "*" || value.toLowerCase() === "latest") {
163
+ return { level: "warn", reason: "dependency has an unbounded version range" }
164
+ }
165
+ if (/^(file:|link:|workspace:|git:|github:|git\+|https?:)/i.test(value)) {
166
+ return { level: "fail", reason: "dependency resolves from a local path, git repo, or URL" }
167
+ }
168
+ return null
169
+ }
170
+
171
+ function lintPackageJson(cwd, out) {
172
+ const packagePath = path.join(cwd, "package.json")
173
+ if (!fs.existsSync(packagePath)) return 0
174
+
175
+ let pkg
176
+ try {
177
+ pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"))
178
+ } catch (err) {
179
+ return out.fail("package.json is not valid JSON", "Fix package.json before publishing.")
180
+ }
181
+
182
+ let failures = 0
183
+ const lifecycleScripts = ["preinstall", "install", "postinstall", "prepare"]
184
+ for (const scriptName of lifecycleScripts) {
185
+ if (pkg.scripts?.[scriptName]) {
186
+ failures += out.fail(
187
+ `package.json defines lifecycle script: ${scriptName}`,
188
+ "Remove install-time scripts from plugin packages; build steps should run before pltt publish.",
189
+ { script: pkg.scripts[scriptName] },
190
+ )
191
+ }
192
+ }
193
+
194
+ const dependencyBlocks = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]
195
+ const inspected = []
196
+ for (const blockName of dependencyBlocks) {
197
+ const block = pkg[blockName]
198
+ if (!block || typeof block !== "object" || Array.isArray(block)) continue
199
+ for (const [name, spec] of Object.entries(block)) {
200
+ inspected.push({ name, spec, block: blockName })
201
+ const risk = dependencySpecRisk(spec)
202
+ if (!risk) continue
203
+ if (risk.level === "fail") {
204
+ failures += out.fail(
205
+ `${blockName}.${name} uses unsafe spec "${spec}"`,
206
+ "Publish plugins with registry dependencies only; vendor or release private code as a package first.",
207
+ )
208
+ } else {
209
+ out.warn(
210
+ `${blockName}.${name} uses broad spec "${spec}"`,
211
+ "Pin dependencies to a semver range such as ^1.2.3 before publishing.",
212
+ )
213
+ }
214
+ }
215
+ }
216
+
217
+ if (failures === 0) out.ok("package.json dependency policy passed", { dependencies: inspected })
218
+ return failures
219
+ }
220
+
221
+ function pythonDependencyRisk(dep) {
222
+ const value = String(dep || "").trim()
223
+ if (/@\s*(https?:|file:)|\bgit\+|^\s*(https?:|file:)/i.test(value)) {
224
+ return { level: "fail", reason: "dependency resolves from a local path, git repo, or URL" }
225
+ }
226
+ if (!/[<>=~!]=?/.test(value)) {
227
+ return { level: "warn", reason: "dependency is not version constrained" }
228
+ }
229
+ return null
230
+ }
231
+
232
+ function extractPyprojectDependencies(src) {
233
+ const deps = []
234
+ src = src
235
+ .split(/\r?\n/)
236
+ .filter((line) => !line.trimStart().startsWith("#"))
237
+ .map((line) => line.replace(/\s+#.*$/, ""))
238
+ .join("\n")
239
+ const match = src.match(/dependencies\s*=\s*\[([\s\S]*?)\]/)
240
+ if (!match) return deps
241
+ const re = /"([^"]+)"|'([^']+)'/g
242
+ let item
243
+ while ((item = re.exec(match[1])) !== null) deps.push(item[1] || item[2])
244
+ return deps
245
+ }
246
+
247
+ function lintPyproject(cwd, out) {
248
+ const pyprojectPath = path.join(cwd, "pyproject.toml")
249
+ if (!fs.existsSync(pyprojectPath)) return 0
250
+
251
+ const src = fs.readFileSync(pyprojectPath, "utf8")
252
+ const deps = extractPyprojectDependencies(src)
253
+ let failures = 0
254
+ for (const dep of deps) {
255
+ const risk = pythonDependencyRisk(dep)
256
+ if (!risk) continue
257
+ if (risk.level === "fail") {
258
+ failures += out.fail(
259
+ `pyproject dependency uses unsafe source: ${dep}`,
260
+ "Publish Python dependencies through a package index or approved wheel before publishing.",
261
+ )
262
+ } else {
263
+ out.warn(
264
+ `pyproject dependency is not version constrained: ${dep}`,
265
+ "Pin Python dependencies with a version constraint before publishing.",
266
+ )
267
+ }
268
+ }
269
+ if (failures === 0) out.ok("pyproject.toml dependency policy passed", { dependencies: deps })
270
+ return failures
271
+ }
272
+
273
+ function lintDependencyPolicy(cwd, out) {
274
+ return lintPackageJson(cwd, out) + lintPyproject(cwd, out)
275
+ }
276
+
277
+ async function run(args, { cwd }) {
278
+ const json = args.includes("--json")
279
+ let failures = 0
280
+ const results = []
281
+ const out = reporter(json, results)
282
+
283
+ let manifest
284
+ try {
285
+ manifest = loadManifest(cwd)
286
+ out.ok("palette-plugin.json found")
287
+ } catch (err) {
288
+ failures += out.fail(
289
+ err instanceof Error ? err.message : String(err),
290
+ "Run pltt test from a plugin root.",
291
+ )
292
+ }
293
+
294
+ if (!manifest) process.exit(1)
295
+
296
+ const manifestErrors = validateManifest(manifest)
297
+ if (manifestErrors.length) {
298
+ for (const err of manifestErrors) failures += out.fail(`manifest invalid: ${err}`)
299
+ } else {
300
+ out.ok(`manifest valid: ${manifest.id}@${manifest.version}`)
301
+ }
302
+
303
+ for (const permission of manifest.permissions || []) {
304
+ if (!KNOWN_PERMISSIONS.has(permission)) {
305
+ failures += out.fail(
306
+ `unknown permission: ${permission}`,
307
+ "Use one of the platform permission strings documented in the SDK guide.",
308
+ )
309
+ }
310
+ }
311
+ if ((manifest.permissions || []).length) out.ok("declared permissions are known")
312
+
313
+ if (manifest.frontend?.entry && manifest.frontend.sandbox === false) {
314
+ failures += out.fail(
315
+ "appstore frontend must be sandboxed",
316
+ 'Set "frontend": { "sandbox": true } or omit the field; direct runtime is reserved for trusted built-in apps.',
317
+ )
318
+ }
319
+
320
+ for (const publicRoute of manifest.public_routes || []) {
321
+ if (!publicRoute.startsWith("/")) {
322
+ failures += out.fail(
323
+ `public route must start with '/': ${publicRoute}`,
324
+ `Change it to "/${publicRoute.replace(/^\/+/, "")}".`,
325
+ )
326
+ }
327
+ }
328
+
329
+ if (manifest.frontend?.entry) {
330
+ try {
331
+ const frontend = await bundleFrontend(cwd, manifest.frontend.entry)
332
+ out.ok(`frontend bundles successfully (${frontend.length} bytes)`, { bytes: frontend.length })
333
+ } catch (err) {
334
+ failures += out.fail(
335
+ `frontend bundle failed: ${err instanceof Error ? err.message : String(err)}`,
336
+ "Fix frontend compile errors before publishing.",
337
+ )
338
+ }
339
+ }
340
+
341
+ if (manifest.backend?.entry) {
342
+ const backendEntry = path.resolve(cwd, manifest.backend.entry)
343
+ if (!fs.existsSync(backendEntry)) {
344
+ failures += out.fail(`backend entry not found: ${manifest.backend.entry}`)
345
+ } else {
346
+ out.ok(`backend entry exists: ${manifest.backend.entry}`)
347
+ failures += checkBackendContract(cwd, manifest, out)
348
+ }
349
+
350
+ try {
351
+ const backend = await bundleBackend(cwd)
352
+ out.ok(`backend package builds successfully (${backend.length} bytes)`, { bytes: backend.length })
353
+ } catch (err) {
354
+ failures += out.fail(
355
+ `backend package failed: ${err instanceof Error ? err.message : String(err)}`,
356
+ "Check backend/ exists and tar is available.",
357
+ )
358
+ }
359
+ }
360
+
361
+ failures += lintMigrations(cwd, manifest, out)
362
+ failures += lintDependencyPolicy(cwd, out)
363
+
364
+ if (failures > 0) {
365
+ if (json) {
366
+ console.log(JSON.stringify({ ok: false, failures, results }, null, 2))
367
+ } else {
368
+ console.log(`\n[palette] test failed with ${failures} issue(s).`)
369
+ }
370
+ process.exit(1)
371
+ }
372
+ if (json) console.log(JSON.stringify({ ok: true, failures: 0, results }, null, 2))
373
+ else console.log("\n[palette] test passed.")
374
+ }
375
+
376
+ module.exports = run
@@ -1,7 +1,7 @@
1
1
  "use strict"
2
2
 
3
3
  /**
4
- * Named environments for `palette publish`.
4
+ * Named environments for `pltt publish`.
5
5
  *
6
6
  * Config is discovered in this order (first hit wins):
7
7
  * 1. ./palette.config.json (plugin-repo-local override)
package/lib/manifest.js CHANGED
@@ -4,6 +4,49 @@ const fs = require("fs")
4
4
  const path = require("path")
5
5
 
6
6
  const MANIFEST_FILE = "palette-plugin.json"
7
+ const SUPPORTED_MANIFEST_VERSIONS = ["1"]
8
+ const KNOWN_PERMISSIONS = new Set([
9
+ "tasks:read",
10
+ "tasks:write",
11
+ "data_rooms:read",
12
+ "data_rooms:write",
13
+ "agents:read",
14
+ "agents:write",
15
+ "chat:read",
16
+ "chat:write",
17
+ "routines:read",
18
+ "routines:write",
19
+ "members:read",
20
+ "resources:read",
21
+ "resources:write",
22
+ ])
23
+
24
+ const TOP_LEVEL_KEYS = new Set([
25
+ "manifest_version",
26
+ "id",
27
+ "name",
28
+ "version",
29
+ "developer",
30
+ "category",
31
+ "tagline",
32
+ "description",
33
+ "icon",
34
+ "gradient",
35
+ "sdk",
36
+ "platform",
37
+ "capabilities",
38
+ "frontend",
39
+ "backend",
40
+ "agents",
41
+ "tools",
42
+ "permissions",
43
+ "ui_extensions",
44
+ "shared_tables",
45
+ "public_routes",
46
+ "rate_limit",
47
+ "database",
48
+ "scheduled_jobs",
49
+ ])
7
50
 
8
51
  function loadManifest(cwd) {
9
52
  const manifestPath = path.join(cwd, MANIFEST_FILE)
@@ -17,19 +60,221 @@ function loadManifest(cwd) {
17
60
  }
18
61
  }
19
62
 
20
- // Lightweight subset validator — full JSON-schema validation happens at
21
- // container startup. This covers the fields the CLI itself consumes.
63
+ function isSemverRange(v) {
64
+ // Accepts "^1.2.3", "~1.2.3", "1.2.3", ">=1.0.0 <2.0.0"
65
+ return typeof v === "string" && /^[\^~>=<\s\d.\-+a-zA-Z*]+$/.test(v) && /\d/.test(v)
66
+ }
67
+
68
+ function isObject(v) {
69
+ return v !== null && typeof v === "object" && !Array.isArray(v)
70
+ }
71
+
72
+ function unknownKeys(obj, allowed, label, errors) {
73
+ if (!isObject(obj)) return
74
+ for (const key of Object.keys(obj)) {
75
+ if (!allowed.has(key)) errors.push(`${label}.${key} is not allowed`)
76
+ }
77
+ }
78
+
79
+ function requireBoolean(obj, key, label, errors) {
80
+ if (obj[key] !== undefined && typeof obj[key] !== "boolean") {
81
+ errors.push(`${label}.${key} must be a boolean`)
82
+ }
83
+ }
84
+
85
+ function requireString(obj, key, label, errors) {
86
+ if (obj[key] !== undefined && typeof obj[key] !== "string") {
87
+ errors.push(`${label}.${key} must be a string`)
88
+ }
89
+ }
90
+
91
+ function validateArray(value, label, errors) {
92
+ if (value !== undefined && !Array.isArray(value)) errors.push(`${label} must be an array`)
93
+ }
94
+
22
95
  function validateManifest(m) {
23
96
  const errors = []
97
+ if (!isObject(m)) return ["manifest must be an object"]
98
+
99
+ unknownKeys(m, TOP_LEVEL_KEYS, "manifest", errors)
100
+
101
+ if (!m.manifest_version) {
102
+ errors.push("manifest_version is required")
103
+ } else if (!SUPPORTED_MANIFEST_VERSIONS.includes(String(m.manifest_version))) {
104
+ errors.push(`manifest_version must be one of ${SUPPORTED_MANIFEST_VERSIONS.join(", ")}`)
105
+ }
24
106
  if (!m.id || typeof m.id !== "string") errors.push("id is required (string)")
25
107
  else if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(m.id))
26
108
  errors.push("id must be lowercase kebab-case")
27
- if (!m.name) errors.push("name is required")
28
- if (!m.version) errors.push("version is required")
29
- else if (!/^\d+\.\d+\.\d+/.test(m.version)) errors.push("version must be semver")
30
- if (m.frontend && !m.frontend.entry) errors.push("frontend.entry is required when frontend is set")
31
- if (m.backend && !m.backend.entry) errors.push("backend.entry is required when backend is set")
109
+ if (!m.name || typeof m.name !== "string") errors.push("name is required (string)")
110
+ if (!m.version || typeof m.version !== "string") errors.push("version is required (string)")
111
+ else if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(m.version)) errors.push("version must be semver")
112
+ requireString(m, "developer", "manifest", errors)
113
+ requireString(m, "category", "manifest", errors)
114
+ requireString(m, "tagline", "manifest", errors)
115
+ requireString(m, "description", "manifest", errors)
116
+ requireString(m, "icon", "manifest", errors)
117
+
118
+ if (m.gradient !== undefined) {
119
+ if (!isObject(m.gradient)) errors.push("gradient must be an object")
120
+ else {
121
+ unknownKeys(m.gradient, new Set(["bg", "text"]), "gradient", errors)
122
+ requireString(m.gradient, "bg", "gradient", errors)
123
+ requireString(m.gradient, "text", "gradient", errors)
124
+ }
125
+ }
126
+
127
+ if (m.frontend !== undefined) {
128
+ if (!isObject(m.frontend)) errors.push("frontend must be an object")
129
+ else {
130
+ unknownKeys(m.frontend, new Set(["entry", "sandbox"]), "frontend", errors)
131
+ if (!m.frontend.entry || typeof m.frontend.entry !== "string") {
132
+ errors.push("frontend.entry is required when frontend is set")
133
+ }
134
+ requireBoolean(m.frontend, "sandbox", "frontend", errors)
135
+ }
136
+ }
137
+ if (m.backend !== undefined) {
138
+ if (!isObject(m.backend)) errors.push("backend must be an object")
139
+ else {
140
+ unknownKeys(m.backend, new Set(["entry", "routes_prefix"]), "backend", errors)
141
+ if (!m.backend.entry || typeof m.backend.entry !== "string") {
142
+ errors.push("backend.entry is required when backend is set")
143
+ }
144
+ requireString(m.backend, "routes_prefix", "backend", errors)
145
+ }
146
+ }
147
+
148
+ if (!m.frontend && !m.backend && (!Array.isArray(m.tools) || m.tools.length === 0)) {
149
+ errors.push("at least one of frontend, backend, or tools is required")
150
+ }
151
+
152
+ if (m.sdk) {
153
+ if (!isObject(m.sdk)) errors.push("sdk must be an object")
154
+ else {
155
+ unknownKeys(m.sdk, new Set(["frontend", "backend"]), "sdk", errors)
156
+ if (m.sdk.frontend !== undefined && !isSemverRange(m.sdk.frontend))
157
+ errors.push("sdk.frontend must be a semver range")
158
+ if (m.sdk.backend !== undefined && !isSemverRange(m.sdk.backend))
159
+ errors.push("sdk.backend must be a semver range")
160
+ }
161
+ }
162
+ if (m.platform) {
163
+ if (!isObject(m.platform)) errors.push("platform must be an object")
164
+ else {
165
+ unknownKeys(m.platform, new Set(["min_version", "max_version"]), "platform", errors)
166
+ if (m.platform.min_version !== undefined && !isSemverRange(m.platform.min_version))
167
+ errors.push("platform.min_version must be a semver range")
168
+ if (m.platform.max_version !== undefined && !isSemverRange(m.platform.max_version))
169
+ errors.push("platform.max_version must be a semver range")
170
+ }
171
+ }
172
+ if (m.capabilities) {
173
+ if (!isObject(m.capabilities)) errors.push("capabilities must be an object")
174
+ else {
175
+ unknownKeys(
176
+ m.capabilities,
177
+ new Set(["frontend", "backend", "database", "webhooks", "scheduled_jobs", "file_uploads", "external_network"]),
178
+ "capabilities",
179
+ errors,
180
+ )
181
+ for (const key of ["frontend", "backend", "database", "webhooks", "scheduled_jobs", "file_uploads"]) {
182
+ requireBoolean(m.capabilities, key, "capabilities", errors)
183
+ }
184
+ if (m.capabilities.external_network !== undefined) {
185
+ if (!Array.isArray(m.capabilities.external_network)) {
186
+ errors.push("capabilities.external_network must be an array of hostnames")
187
+ } else {
188
+ for (const host of m.capabilities.external_network) {
189
+ if (typeof host !== "string" || !/^[a-zA-Z0-9.-]+(?::\d+)?$/.test(host)) {
190
+ errors.push(`capabilities.external_network contains invalid hostname: ${host}`)
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ validateArray(m.public_routes, "public_routes", errors)
198
+ if (Array.isArray(m.public_routes)) {
199
+ for (const route of m.public_routes) {
200
+ if (typeof route !== "string" || !route.startsWith("/")) {
201
+ errors.push(`public route must start with '/': ${route}`)
202
+ }
203
+ }
204
+ }
205
+
206
+ if (m.scheduled_jobs !== undefined) {
207
+ if (!Array.isArray(m.scheduled_jobs)) errors.push("scheduled_jobs must be an array")
208
+ else {
209
+ m.scheduled_jobs.forEach((j, i) => {
210
+ if (!isObject(j)) {
211
+ errors.push(`scheduled_jobs[${i}] must be an object`)
212
+ return
213
+ }
214
+ unknownKeys(j, new Set(["name", "schedule", "handler"]), `scheduled_jobs[${i}]`, errors)
215
+ if (!j.name || typeof j.name !== "string") errors.push(`scheduled_jobs[${i}].name is required`)
216
+ if (!j.schedule || typeof j.schedule !== "string") errors.push(`scheduled_jobs[${i}].schedule is required`)
217
+ if (!j.handler || typeof j.handler !== "string") errors.push(`scheduled_jobs[${i}].handler is required`)
218
+ })
219
+ }
220
+ }
221
+ if (m.rate_limit) {
222
+ if (!isObject(m.rate_limit)) errors.push("rate_limit must be an object")
223
+ else {
224
+ unknownKeys(m.rate_limit, new Set(["per_minute"]), "rate_limit", errors)
225
+ if (
226
+ m.rate_limit.per_minute !== undefined &&
227
+ (!Number.isInteger(m.rate_limit.per_minute) ||
228
+ m.rate_limit.per_minute < 1 ||
229
+ m.rate_limit.per_minute > 10000)
230
+ ) {
231
+ errors.push("rate_limit.per_minute must be an integer between 1 and 10000")
232
+ }
233
+ }
234
+ }
235
+ if (m.database) {
236
+ if (!isObject(m.database)) errors.push("database must be an object")
237
+ else {
238
+ unknownKeys(m.database, new Set(["schema", "migrations"]), "database", errors)
239
+ if (!m.database.migrations || typeof m.database.migrations !== "string") {
240
+ errors.push("database.migrations is required when database is set")
241
+ }
242
+ if (m.database.schema !== undefined && !/^app_[a-z0-9_]+$/.test(m.database.schema)) {
243
+ errors.push("database.schema must match /^app_[a-z0-9_]+$/")
244
+ }
245
+ }
246
+ }
247
+
248
+ validateArray(m.shared_tables, "shared_tables", errors)
249
+ validateArray(m.permissions, "permissions", errors)
250
+ if (Array.isArray(m.permissions)) {
251
+ const seen = new Set()
252
+ for (const permission of m.permissions) {
253
+ if (typeof permission !== "string") {
254
+ errors.push("permissions entries must be strings")
255
+ } else if (!KNOWN_PERMISSIONS.has(permission)) {
256
+ errors.push(`unknown permission: ${permission}`)
257
+ } else if (seen.has(permission)) {
258
+ errors.push(`duplicate permission: ${permission}`)
259
+ }
260
+ seen.add(permission)
261
+ }
262
+ }
263
+
264
+ validateArray(m.tools, "tools", errors)
265
+ if (Array.isArray(m.tools)) {
266
+ m.tools.forEach((tool, i) => {
267
+ if (!isObject(tool)) {
268
+ errors.push(`tools[${i}] must be an object`)
269
+ return
270
+ }
271
+ unknownKeys(tool, new Set(["name", "description", "entry"]), `tools[${i}]`, errors)
272
+ if (!tool.name || typeof tool.name !== "string") errors.push(`tools[${i}].name is required`)
273
+ if (!tool.entry || typeof tool.entry !== "string") errors.push(`tools[${i}].entry is required`)
274
+ requireString(tool, "description", `tools[${i}]`, errors)
275
+ })
276
+ }
32
277
  return errors
33
278
  }
34
279
 
35
- module.exports = { loadManifest, validateManifest }
280
+ module.exports = { loadManifest, validateManifest, SUPPORTED_MANIFEST_VERSIONS, KNOWN_PERMISSIONS }