@palettelab/cli 0.3.46 → 0.3.47

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.
@@ -31,6 +31,22 @@ function pluginTablePrefix(pluginId) {
31
31
  return `${pluginSafeId(pluginId)}__`
32
32
  }
33
33
 
34
+ function makeIssue(message, fix = null, meta = {}) {
35
+ return { message, fix, ...meta }
36
+ }
37
+
38
+ function formatIssue(issue) {
39
+ if (typeof issue === "string") return issue
40
+ const lines = [issue.message || String(issue)]
41
+ if (issue.details) lines.push(`Why: ${issue.details}`)
42
+ if (issue.fix) lines.push(`Fix: ${issue.fix}`)
43
+ if (issue.example) {
44
+ lines.push("Example:")
45
+ for (const line of String(issue.example).split("\n")) lines.push(` ${line}`)
46
+ }
47
+ return lines.join("\n ")
48
+ }
49
+
34
50
  function crossPluginSchemaRefs(src, allowedSchema) {
35
51
  if (!allowedSchema) return []
36
52
  const refs = new Set()
@@ -43,28 +59,27 @@ function crossPluginSchemaRefs(src, allowedSchema) {
43
59
  return [...refs].sort()
44
60
  }
45
61
 
46
- function lintMigrationFile(absPath, pluginId) {
62
+ function lintMigrationFile(absPath, pluginId, sharedTables = []) {
47
63
  const issues = []
48
64
  const src = fs.readFileSync(absPath, "utf8")
49
65
  const requiredPrefix = pluginId ? pluginTablePrefix(pluginId) : null
50
66
  const allowedSchema = pluginId ? pluginSchema(pluginId) : null
67
+ const sharedTableNames = new Set(sharedTables || [])
51
68
 
52
69
  for (const { re, reason } of BANNED_PATTERNS) {
53
70
  if (re.test(src)) {
54
- issues.push(`${path.basename(absPath)}: ${reason}`)
71
+ issues.push(makeIssue(`${path.basename(absPath)}: ${reason}`))
55
72
  }
56
73
  }
57
74
 
58
75
  for (const schema of crossPluginSchemaRefs(src, allowedSchema)) {
59
- issues.push(`${path.basename(absPath)}: plugin migrations must not reference another app schema (${schema})`)
76
+ issues.push(makeIssue(`${path.basename(absPath)}: plugin migrations must not reference another app schema (${schema})`))
60
77
  }
61
78
 
62
79
  // Every op.create_table("foo", ...) in the file must have a matching
63
- // ensure_org_rls(op, "foo") somewhere in the same file. Caveat: this is a
64
- // cheap syntactic check, not a full AST walk. If your table name is dynamic
65
- // or your migration is unusual, you can silence the check by adding the
66
- // magic comment `# palette:rls-ok` on the same logical migration.
67
- const skipRlsCheck = /#\s*palette:rls-ok\b/.test(src)
80
+ // ensure_org_rls(op, "foo") somewhere in the same file unless the table is
81
+ // declared in manifest.shared_tables. Caveat: this is a cheap syntactic
82
+ // check, not a full AST walk.
68
83
 
69
84
  const tableNames = new Set()
70
85
  const createTableRe = /op\.create_table\(\s*['"]([a-zA-Z_][a-zA-Z0-9_]*)['"]/g
@@ -75,23 +90,48 @@ function lintMigrationFile(absPath, pluginId) {
75
90
 
76
91
  for (const name of tableNames) {
77
92
  if (requiredPrefix && !name.startsWith(requiredPrefix)) {
78
- issues.push(
93
+ issues.push(makeIssue(
79
94
  `${path.basename(absPath)}: create_table("${name}") must use the app table prefix "${requiredPrefix}"`,
80
- )
95
+ `Rename the table to "${requiredPrefix}${name}" or another name that starts with "${requiredPrefix}", then update model and query references.`,
96
+ {
97
+ code: "database_table_prefix",
98
+ file: path.basename(absPath),
99
+ table: name,
100
+ required_prefix: requiredPrefix,
101
+ },
102
+ ))
81
103
  }
82
104
  const rlsRe = new RegExp(`ensure_org_rls\\(\\s*op\\s*,\\s*['"]${name}['"]`)
83
- if (!skipRlsCheck && !rlsRe.test(src)) {
84
- issues.push(
105
+ if (!sharedTableNames.has(name) && !rlsRe.test(src)) {
106
+ const schemaHint = pluginId ? `${pluginSchema(pluginId)}.${name}` : name
107
+ issues.push(makeIssue(
85
108
  `${path.basename(absPath)}: create_table("${name}") is missing ensure_org_rls(op, "${name}"). ` +
86
- `Inherit from OrgScopedTable and call ensure_org_rls, or mark the migration with # palette:rls-ok if the table is intentionally global.`,
87
- )
109
+ `Preview/install will fail RLS compliance for ${schemaHint}: missing ENABLE + FORCE row level security.`,
110
+ `Import ensure_org_rls from palette_sdk.db and call ensure_org_rls(op, "${name}") immediately after op.create_table("${name}", ...). ` +
111
+ `Keep an organization_id column on tenant data tables. If this table is intentionally shared across every organization, add "${name}" to manifest.shared_tables instead and document why it is global.`,
112
+ {
113
+ code: "database_missing_org_rls",
114
+ file: path.basename(absPath),
115
+ table: name,
116
+ schema: pluginId ? pluginSchema(pluginId) : null,
117
+ details:
118
+ "Palette verifies every plugin table after migrations run. Tables that hold tenant data must have organization_id, at least one RLS policy, and ENABLE + FORCE row level security.",
119
+ example: [
120
+ "from palette_sdk.db import ensure_org_rls",
121
+ "",
122
+ "def upgrade():",
123
+ ` op.create_table("${name}", ...)`,
124
+ ` ensure_org_rls(op, "${name}")`,
125
+ ].join("\n"),
126
+ },
127
+ ))
88
128
  }
89
129
  }
90
130
 
91
131
  return issues
92
132
  }
93
133
 
94
- function lintMigrationsDir(migrationsDir, pluginId) {
134
+ function lintMigrationsDir(migrationsDir, pluginId, sharedTables = []) {
95
135
  const errors = []
96
136
  const versionsDir = path.join(migrationsDir, "versions")
97
137
  if (!fs.existsSync(versionsDir)) {
@@ -101,7 +141,7 @@ function lintMigrationsDir(migrationsDir, pluginId) {
101
141
  for (const entry of fs.readdirSync(versionsDir)) {
102
142
  if (!entry.endsWith(".py")) continue
103
143
  const abs = path.join(versionsDir, entry)
104
- errors.push(...lintMigrationFile(abs, pluginId))
144
+ errors.push(...lintMigrationFile(abs, pluginId, sharedTables))
105
145
  }
106
146
  return errors
107
147
  }
@@ -127,13 +167,13 @@ async function run(args, { cwd }) {
127
167
  } else if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
128
168
  errors.push(`database.migrations directory is missing env.py: ${migrationsRel}`)
129
169
  } else {
130
- errors.push(...lintMigrationsDir(migrationsAbs, manifest.id))
170
+ errors.push(...lintMigrationsDir(migrationsAbs, manifest.id, manifest.shared_tables || []))
131
171
  }
132
172
  }
133
173
 
134
174
  if (errors.length) {
135
175
  console.error("[pltt] validation failed:")
136
- for (const e of errors) console.error(` - ${e}`)
176
+ for (const e of errors) console.error(` - ${formatIssue(e)}`)
137
177
  process.exit(1)
138
178
  }
139
179
  console.log(`[pltt] ok — ${manifest.id} v${manifest.version}`)
@@ -142,4 +182,5 @@ async function run(args, { cwd }) {
142
182
  module.exports = run
143
183
  module.exports.lintMigrationsDir = lintMigrationsDir
144
184
  module.exports.lintMigrationFile = lintMigrationFile
185
+ module.exports.formatIssue = formatIssue
145
186
  module.exports.pluginTablePrefix = pluginTablePrefix
@@ -1,5 +1,6 @@
1
1
  "use strict"
2
2
 
3
+ const fs = require("fs")
3
4
  const path = require("path")
4
5
  const { spawn, spawnSync } = require("child_process")
5
6
  const { loadManifest } = require("../manifest")
@@ -8,6 +9,7 @@ const { parseFlags, resolveEnvironment } = require("../environments")
8
9
  const { resolveDevPorts } = require("../ports")
9
10
  const { startSimulator } = require("../dev-simulator")
10
11
  const { loadLocalEnv } = require("../secrets")
12
+ const buildCommand = require("./build")
11
13
  const publish = require("./publish")
12
14
  const logs = require("./logs")
13
15
 
@@ -76,6 +78,26 @@ function imagePullHelp(image, output) {
76
78
  )
77
79
  }
78
80
 
81
+ function lintMigrationsForDev(cwd, manifest) {
82
+ if (!manifest.database) return
83
+ const migrationsRel = manifest.database.migrations || "./backend/migrations"
84
+ const migrationsAbs = path.resolve(cwd, migrationsRel)
85
+ const errors = []
86
+ if (!fs.existsSync(migrationsAbs)) {
87
+ errors.push(`database.migrations directory not found: ${migrationsRel}`)
88
+ } else if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
89
+ errors.push(`database.migrations directory is missing env.py: ${migrationsRel}`)
90
+ } else {
91
+ errors.push(...buildCommand.lintMigrationsDir(migrationsAbs, manifest.id, manifest.shared_tables || []))
92
+ }
93
+ if (!errors.length) return
94
+
95
+ console.error("[pltt] dev blocked by database migration validation:")
96
+ for (const err of errors) console.error(` - ${buildCommand.formatIssue(err)}`)
97
+ console.error("[pltt] Fix these issues or run `pltt test --json` for machine-readable details.")
98
+ process.exit(1)
99
+ }
100
+
79
101
  async function run(args, { cwd }) {
80
102
  const { flags, rest } = parseFlags(args)
81
103
  const cloud = rest.includes("--cloud") || rest.includes("--sandbox")
@@ -124,6 +146,7 @@ async function run(args, { cwd }) {
124
146
  }
125
147
 
126
148
  const manifest = loadManifest(cwd)
149
+ lintMigrationsForDev(cwd, manifest)
127
150
  const pluginId = manifest.id
128
151
  const ports = await resolveDevPorts({ host: platform ? "0.0.0.0" : "127.0.0.1" })
129
152
 
@@ -5,6 +5,7 @@ const path = require("path")
5
5
  const crypto = require("crypto")
6
6
  const { loadManifest, validateManifest } = require("../manifest")
7
7
  const { bundleFrontend, bundleBackend } = require("../bundler")
8
+ const buildCommand = require("./build")
8
9
 
9
10
  function sha256(buf) {
10
11
  return crypto.createHash("sha256").update(buf).digest("hex")
@@ -20,6 +21,24 @@ async function run(argv, { cwd }) {
20
21
  process.exit(1)
21
22
  }
22
23
 
24
+ if (manifest.database) {
25
+ const migrationsRel = manifest.database.migrations || "./backend/migrations"
26
+ const migrationsAbs = path.resolve(cwd, migrationsRel)
27
+ const migrationErrors = []
28
+ if (!fs.existsSync(migrationsAbs)) {
29
+ migrationErrors.push(`database.migrations directory not found: ${migrationsRel}`)
30
+ } else if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
31
+ migrationErrors.push(`database.migrations directory is missing env.py: ${migrationsRel}`)
32
+ } else {
33
+ migrationErrors.push(...buildCommand.lintMigrationsDir(migrationsAbs, manifest.id, manifest.shared_tables || []))
34
+ }
35
+ if (migrationErrors.length) {
36
+ console.error("[pltt] package blocked by database migration validation:")
37
+ for (const e of migrationErrors) console.error(` - ${buildCommand.formatIssue(e)}`)
38
+ process.exit(1)
39
+ }
40
+ }
41
+
23
42
  const distDir = path.join(cwd, "dist")
24
43
  fs.mkdirSync(distDir, { recursive: true })
25
44
 
@@ -80,14 +80,21 @@ function explainPreflightFailure(result) {
80
80
  }
81
81
  }
82
82
 
83
- const migration = extractMigrationHint(message)
83
+ const migration =
84
+ result?.code === "database_missing_org_rls" && result?.table
85
+ ? { file: result.file || "migration", table: result.table }
86
+ : extractMigrationHint(message)
84
87
  if (migration) {
85
88
  return {
86
89
  title: "Database migration is missing organization RLS",
87
- details: [`File: ${migration.file}`, `Table: ${migration.table}`],
90
+ details: [
91
+ `File: ${migration.file}`,
92
+ `Table: ${migration.table}`,
93
+ "Preview/install will reject this table unless RLS is enabled and forced.",
94
+ ],
88
95
  fix:
89
96
  fix ||
90
- `Call ensure_org_rls(op, "${migration.table}") after create_table, or add # palette:rls-ok only if the table is intentionally global.`,
97
+ `Call ensure_org_rls(op, "${migration.table}") after create_table, or add "${migration.table}" to manifest.shared_tables only if the table is intentionally global.`,
91
98
  }
92
99
  }
93
100
 
@@ -12,6 +12,14 @@ const DEFAULT_FRONTEND_BUNDLE_LIMIT = 15 * 1024 * 1024
12
12
  const DEFAULT_BACKEND_BUNDLE_LIMIT = 15 * 1024 * 1024
13
13
 
14
14
  function reporter(json, results) {
15
+ function printExtra(meta) {
16
+ if (meta.details) console.log(`WHY ${meta.details}`)
17
+ if (meta.example) {
18
+ console.log("EXAMPLE")
19
+ for (const line of String(meta.example).split("\n")) console.log(` ${line}`)
20
+ }
21
+ }
22
+
15
23
  return {
16
24
  ok(message, meta = {}) {
17
25
  results.push({ status: "ok", message, ...meta })
@@ -22,6 +30,7 @@ function reporter(json, results) {
22
30
  if (!json) {
23
31
  console.log(`WARN ${message}`)
24
32
  if (fix) console.log(`FIX ${fix}`)
33
+ printExtra(meta)
25
34
  }
26
35
  return 0
27
36
  },
@@ -30,6 +39,7 @@ function reporter(json, results) {
30
39
  if (!json) {
31
40
  console.log(`FAIL ${message}`)
32
41
  if (fix) console.log(`FIX ${fix}`)
42
+ printExtra(meta)
33
43
  }
34
44
  return 1
35
45
  },
@@ -253,8 +263,15 @@ function lintMigrations(cwd, manifest, out) {
253
263
  if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
254
264
  return out.fail(`database.migrations directory is missing env.py: ${migrationsRel}`)
255
265
  }
256
- const errors = buildCommand.lintMigrationsDir(migrationsAbs, manifest.id)
257
- for (const err of errors) out.fail(err)
266
+ const errors = buildCommand.lintMigrationsDir(migrationsAbs, manifest.id, manifest.shared_tables || [])
267
+ for (const err of errors) {
268
+ if (typeof err === "string") {
269
+ out.fail(err)
270
+ } else {
271
+ const { message, fix, ...meta } = err
272
+ out.fail(message, fix, meta)
273
+ }
274
+ }
258
275
  if (errors.length === 0) out.ok("migration lint passed")
259
276
  return errors.length
260
277
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.46",
3
+ "version": "0.3.47",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.17"
7
+ "@palettelab/sdk": "^0.1.20"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.17",
6
+ "@palettelab/sdk": "^0.1.20",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-db-plugin",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.17", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.20", "react": "^19.0.0" }
6
6
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-external-svc",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.17", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.20", "react": "^19.0.0" }
6
6
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.17",
6
+ "@palettelab/sdk": "^0.1.20",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.17",
6
+ "@palettelab/sdk": "^0.1.20",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.17",
6
+ "@palettelab/sdk": "^0.1.20",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {