@palettelab/cli 0.3.0 → 0.3.2

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 +103 -10
  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
package/README.md CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  Developer CLI for building plugins for the Palette platform. Works without any access to the platform source — your plugin repo is the only thing you own.
4
4
 
5
+ The installed executable is `pltt`.
6
+
5
7
  ## Requirements
6
8
 
7
9
  - Node.js 18+
8
- - Docker Desktop (for `palette dev`)
10
+ - Docker Desktop (for `pltt dev`)
9
11
 
10
12
  ## Install
11
13
 
@@ -13,27 +15,38 @@ You don't have to install globally — use `npx`:
13
15
 
14
16
  ```bash
15
17
  npx @palettelab/cli <command>
18
+ # or after global install
19
+ pltt <command>
16
20
  ```
17
21
 
18
22
  ## Commands
19
23
 
20
- ### `palette init <name>`
24
+ ### `pltt init <name>`
21
25
 
22
26
  Scaffold a new plugin directory from the official template.
23
27
 
24
28
  ```bash
25
- npx @palettelab/cli init data-explorer
29
+ pltt init data-explorer
30
+ pltt init crm-dashboard --template dashboard
26
31
  cd data-explorer
27
32
  ```
28
33
 
29
34
  Creates `data-explorer/` with a valid `palette-plugin.json`, a frontend React entry, and a FastAPI backend entry.
30
35
 
31
- ### `palette dev`
36
+ Templates:
37
+
38
+ - `dashboard`
39
+ - `agent-tool`
40
+ - `external-service`
41
+ - `database`
42
+ - `frontend-only`
43
+
44
+ ### `pltt dev`
32
45
 
33
46
  Boot the platform locally with your plugin mounted live. Run this from inside your plugin directory.
34
47
 
35
48
  ```bash
36
- npx @palettelab/cli dev
49
+ pltt dev
37
50
  ```
38
51
 
39
52
  Under the hood this runs `docker compose up` with a bundled compose file. It starts:
@@ -53,22 +66,102 @@ Environment variables:
53
66
  | `PALETTE_FRONTEND_PORT` | `3000` | Host port for the frontend |
54
67
  | `PALETTE_BACKEND_PORT` | `8000` | Host port for the backend |
55
68
 
56
- ### `palette build`
69
+ ### `pltt doctor`
70
+
71
+ Check local tooling and common setup problems.
72
+
73
+ ```bash
74
+ pltt doctor
75
+ ```
76
+
77
+ Checks include Node version, Docker availability, platform image availability, port availability, manifest validity, entry files, and frontend bundling.
78
+
79
+ ### `pltt build`
57
80
 
58
81
  Validate `palette-plugin.json` and check that all declared entry files exist. Run this before pushing a release.
59
82
 
60
83
  ```bash
61
- npx @palettelab/cli build
84
+ pltt build
85
+ ```
86
+
87
+ Also lints plugin migrations for unsafe RLS patterns when `database.migrations` is declared.
88
+
89
+ ### `pltt test`
90
+
91
+ Run local contract checks before publishing.
92
+
93
+ ```bash
94
+ pltt test
95
+ pltt test --json
62
96
  ```
63
97
 
98
+ Checks include manifest validity, frontend bundling, backend import, route permission gates, route permission declarations, migration linting, frontend sandbox policy, and dependency policy for `package.json` / `pyproject.toml`.
99
+
100
+ ### `pltt package`
101
+
102
+ Bundle the plugin into `dist/<plugin-id>-<version>.tar.gz` without uploading.
103
+
104
+ ```bash
105
+ pltt package
106
+ pltt package --json
107
+ ```
108
+
109
+ Use this to inspect the publishable artifact before sending it to a Palette environment.
110
+
111
+ ### `pltt publish`
112
+
113
+ Build and publish the plugin to a Palette appstore environment.
114
+
115
+ ```bash
116
+ pltt publish --env local
117
+ pltt publish --env staging
118
+ pltt publish --env production
119
+ pltt publish --env production -y
120
+ pltt publish --env staging --json
121
+ ```
122
+
123
+ Publishing bundles frontend/backend artifacts, uploads them, creates a `pending_review` publish record, and prints review/preview URLs when the platform returns them.
124
+
125
+ Environment config is read from `./palette.config.json`, `~/.palette/config.json`, or `PALETTE_<ENV>_URL` plus `PALETTE_<ENV>_TOKEN` / `PALETTE_PUBLISH_TOKEN`.
126
+
127
+ ### `pltt status <publish-id>`
128
+
129
+ Show review status and automated risk report details for a publish.
130
+
131
+ ```bash
132
+ pltt status 123 --env staging
133
+ pltt status --json
134
+ ```
135
+
136
+ If no publish ID is provided, the CLI uses `.palette/last-publish.json` when available.
137
+
138
+ ### `pltt logs [plugin-id]`
139
+
140
+ Fetch or stream telemetry events for a plugin.
141
+
142
+ ```bash
143
+ pltt logs hello-sdk --env staging
144
+ pltt logs --tail 100
145
+ pltt logs --follow
146
+ pltt logs --json
147
+ ```
148
+
149
+ If no plugin ID is provided, the CLI uses the current `palette-plugin.json` or `.palette/last-publish.json`.
150
+
151
+ ## Global Flags
152
+
153
+ - `--json` emits machine-readable output for `package`, `publish`, `status`, `logs`, and `test`.
154
+ - `--env <name>` selects a configured publish/status/logs environment.
155
+ - `-y, --yes` skips production publish confirmation.
156
+
64
157
  ## What gets shipped
65
158
 
66
159
  `@palettelab/cli` itself contains only:
67
160
 
68
- - `bin/palette.js` — entry point
161
+ - `bin/pltt.js` — entry point
69
162
  - `lib/` — pure-Node command implementations (no runtime dependencies)
70
- - `platform-dev/docker-compose.yml` — compose file for `palette dev`
71
- - `template-fallback/` — offline fallback for `palette init` if git is unavailable
163
+ - `platform-dev/docker-compose.yml` — compose file for `pltt dev`
164
+ - `template-fallback/` — offline fallback for `pltt init` if git is unavailable
72
165
 
73
166
  ## See also
74
167
 
@@ -5,6 +5,6 @@ const path = require("path")
5
5
  const cli = require(path.join(__dirname, "..", "lib", "cli.js"))
6
6
 
7
7
  cli.run(process.argv.slice(2)).catch((err) => {
8
- console.error(`[palette] ${err.message || err}`)
8
+ console.error(`[pltt] ${err.message || err}`)
9
9
  process.exit(1)
10
10
  })
package/lib/bundler.js CHANGED
@@ -5,14 +5,14 @@ const fs = require("fs")
5
5
  const os = require("os")
6
6
 
7
7
  // esbuild is declared as a dependency in package.json; installed via npm install.
8
- // We require it lazily so `palette init` / `palette dev` / `palette build` do
8
+ // We require it lazily so `pltt init` / `pltt dev` / `pltt build` do
9
9
  // not pay the load cost.
10
10
  function loadEsbuild() {
11
11
  try {
12
12
  return require("esbuild")
13
13
  } catch (err) {
14
14
  throw new Error(
15
- "esbuild is required for `palette publish`. " +
15
+ "esbuild is required for `pltt publish`. " +
16
16
  "Run `npm install` inside your plugin directory, or install it globally.",
17
17
  )
18
18
  }
@@ -27,6 +27,7 @@ function loadEsbuild() {
27
27
  * Returns the bundle as a Buffer.
28
28
  */
29
29
  async function bundleFrontend(pluginDir, entry) {
30
+ pluginDir = path.resolve(pluginDir)
30
31
  const esbuild = loadEsbuild()
31
32
  const absEntry = path.resolve(pluginDir, entry)
32
33
  if (!fs.existsSync(absEntry)) {
@@ -42,7 +43,14 @@ async function bundleFrontend(pluginDir, entry) {
42
43
  write: false,
43
44
  jsx: "automatic",
44
45
  loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
45
- external: ["react", "react-dom", "react-dom/client", "@palettelab/sdk"],
46
+ external: [
47
+ "react",
48
+ "react-dom",
49
+ "react-dom/client",
50
+ "react/jsx-runtime",
51
+ "react/jsx-dev-runtime",
52
+ "@palettelab/sdk",
53
+ ],
46
54
  minify: true,
47
55
  sourcemap: "inline",
48
56
  logLevel: "silent",
@@ -55,6 +63,63 @@ async function bundleFrontend(pluginDir, entry) {
55
63
  return Buffer.from(result.outputFiles[0].contents)
56
64
  }
57
65
 
66
+ async function watchFrontend(pluginDir, entry, outfile) {
67
+ pluginDir = path.resolve(pluginDir)
68
+ outfile = path.resolve(outfile)
69
+ const esbuild = loadEsbuild()
70
+ const absEntry = path.resolve(pluginDir, entry)
71
+ if (!fs.existsSync(absEntry)) {
72
+ throw new Error(`frontend entry not found: ${entry}`)
73
+ }
74
+
75
+ fs.mkdirSync(path.dirname(outfile), { recursive: true })
76
+
77
+ const ctx = await esbuild.context({
78
+ entryPoints: [absEntry],
79
+ bundle: true,
80
+ format: "esm",
81
+ platform: "browser",
82
+ target: ["es2022"],
83
+ outfile,
84
+ jsx: "automatic",
85
+ loader: { ".ts": "tsx", ".tsx": "tsx", ".js": "jsx", ".jsx": "jsx" },
86
+ external: [
87
+ "react",
88
+ "react-dom",
89
+ "react-dom/client",
90
+ "react/jsx-runtime",
91
+ "react/jsx-dev-runtime",
92
+ "@palettelab/sdk",
93
+ ],
94
+ minify: false,
95
+ sourcemap: "inline",
96
+ logLevel: "silent",
97
+ absWorkingDir: pluginDir,
98
+ plugins: [
99
+ {
100
+ name: "palette-dev-watch-logger",
101
+ setup(build) {
102
+ build.onEnd((result) => {
103
+ if (result.errors.length) {
104
+ console.error("[palette] frontend bundle failed:")
105
+ for (const err of result.errors) {
106
+ console.error(` - ${err.text}`)
107
+ }
108
+ return
109
+ }
110
+ const size = fs.existsSync(outfile) ? fs.statSync(outfile).size : 0
111
+ console.log(`[palette] frontend bundle ready (${size} bytes)`)
112
+ })
113
+ },
114
+ },
115
+ ],
116
+ })
117
+
118
+ await ctx.rebuild()
119
+ await ctx.watch()
120
+ return ctx
121
+ }
122
+
58
123
  /**
59
124
  * Tar-gzip the `backend/` directory + manifest using the system `tar` command.
60
125
  * Returns a Buffer with the gzipped tarball contents.
@@ -64,12 +129,16 @@ async function bundleFrontend(pluginDir, entry) {
64
129
  * ./palette-plugin.json
65
130
  */
66
131
  async function bundleBackend(pluginDir) {
132
+ pluginDir = path.resolve(pluginDir)
67
133
  const { spawnSync } = require("child_process")
68
134
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "palette-bundle-"))
69
135
  const outPath = path.join(tmp, "backend.tar.gz")
70
136
 
71
137
  const includes = []
72
138
  if (fs.existsSync(path.join(pluginDir, "backend"))) includes.push("backend")
139
+ for (const metadataFile of ["package.json", "pyproject.toml"]) {
140
+ if (fs.existsSync(path.join(pluginDir, metadataFile))) includes.push(metadataFile)
141
+ }
73
142
  includes.push("palette-plugin.json")
74
143
 
75
144
  const result = spawnSync(
@@ -87,4 +156,4 @@ async function bundleBackend(pluginDir) {
87
156
  return buf
88
157
  }
89
158
 
90
- module.exports = { bundleFrontend, bundleBackend }
159
+ module.exports = { bundleFrontend, bundleBackend, watchFrontend }
package/lib/cli.js CHANGED
@@ -1,10 +1,14 @@
1
1
  "use strict"
2
2
 
3
- const path = require("path")
4
3
  const init = require("./commands/init")
5
4
  const dev = require("./commands/dev")
5
+ const doctor = require("./commands/doctor")
6
6
  const build = require("./commands/build")
7
+ const test = require("./commands/test")
7
8
  const publish = require("./commands/publish")
9
+ const pkg = require("./commands/package")
10
+ const status = require("./commands/status")
11
+ const logs = require("./commands/logs")
8
12
 
9
13
  const COMMANDS = {
10
14
  init: { run: init, help: "Scaffold a new plugin directory from the template" },
@@ -12,30 +16,51 @@ const COMMANDS = {
12
16
  run: dev,
13
17
  help: "Boot the platform-dev container and mount the current plugin for live development",
14
18
  },
19
+ doctor: {
20
+ run: doctor,
21
+ help: "Check local tooling, ports, manifest, Docker, and frontend bundling",
22
+ },
15
23
  build: { run: build, help: "Validate palette-plugin.json and check entry points exist" },
24
+ test: { run: test, help: "Run local plugin contract checks before publishing" },
25
+ package: { run: pkg, help: "Bundle the plugin into a tar.gz under dist/ (no upload)" },
16
26
  publish: {
17
27
  run: publish,
18
28
  help: "Bundle and publish the plugin to the platform appstore",
19
29
  },
30
+ status: {
31
+ run: status,
32
+ help: "Show review status + risk report for a published version",
33
+ },
34
+ logs: {
35
+ run: logs,
36
+ help: "Tail telemetry events for a plugin (--follow to stream)",
37
+ },
20
38
  }
21
39
 
22
40
  function printHelp() {
23
- console.log("palette — Palette plugin developer CLI\n")
24
- console.log("Usage: palette <command> [options]\n")
41
+ console.log("pltt — Palette plugin developer CLI\n")
42
+ console.log("Usage: pltt <command> [options]\n")
25
43
  console.log("Commands:")
26
44
  for (const [name, { help }] of Object.entries(COMMANDS)) {
27
45
  console.log(` ${name.padEnd(8)} ${help}`)
28
46
  }
47
+ console.log("\nGlobal flags:")
48
+ console.log(" --json Emit machine-readable JSON output (status, logs, package, publish, test)")
29
49
  console.log("\nPublish flags:")
30
- console.log(" --env <name> Target environment from ~/.palette/config.json (default: local)")
31
- console.log(" -y, --yes Skip interactive confirmation for production pushes")
50
+ console.log(" --env <name> Target environment from ~/.palette/config.json (default: local)")
51
+ console.log(" -y, --yes Skip interactive confirmation for production pushes")
52
+ console.log("\nInit flags:")
53
+ console.log(" --template <name> One of: dashboard, agent-tool, external-service, database, frontend-only")
54
+ console.log("\nLogs flags:")
55
+ console.log(" --tail <n> Tail last n events (default 50)")
56
+ console.log(" -f, --follow Stream events (poll every 3s)")
32
57
  console.log("\nExamples:")
33
- console.log(" palette init my-app")
34
- console.log(" cd my-app && palette dev")
35
- console.log(" palette build")
36
- console.log(" palette publish # default (local)")
37
- console.log(" palette publish --env staging")
38
- console.log(" palette publish --env production # prompts for confirmation")
58
+ console.log(" pltt init my-app --template database")
59
+ console.log(" cd my-app && pltt dev")
60
+ console.log(" pltt package")
61
+ console.log(" pltt publish --env staging")
62
+ console.log(" pltt status")
63
+ console.log(" pltt logs --follow")
39
64
  }
40
65
 
41
66
  async function run(argv) {
@@ -46,7 +71,7 @@ async function run(argv) {
46
71
  }
47
72
  const entry = COMMANDS[cmd]
48
73
  if (!entry) {
49
- console.error(`[palette] unknown command: ${cmd}`)
74
+ console.error(`[pltt] unknown command: ${cmd}`)
50
75
  printHelp()
51
76
  process.exit(1)
52
77
  }
@@ -101,3 +101,5 @@ async function run(args, { cwd }) {
101
101
  }
102
102
 
103
103
  module.exports = run
104
+ module.exports.lintMigrationsDir = lintMigrationsDir
105
+ module.exports.lintMigrationFile = lintMigrationFile
@@ -3,6 +3,7 @@
3
3
  const path = require("path")
4
4
  const { spawn, spawnSync } = require("child_process")
5
5
  const { loadManifest } = require("../manifest")
6
+ const { watchFrontend } = require("../bundler")
6
7
 
7
8
  const DEFAULT_IMAGE =
8
9
  process.env.PALETTE_DEV_IMAGE || "ghcr.io/palette-lab/platform-dev:latest"
@@ -14,23 +15,54 @@ function ensureDocker() {
14
15
  const check = spawnSync("docker", ["info"], { stdio: "ignore" })
15
16
  if (check.status !== 0) {
16
17
  console.error(
17
- "[palette] docker is required for `palette dev`. Install Docker Desktop and make sure it is running.",
18
+ "[pltt] docker is required for `pltt dev`. Install Docker Desktop and make sure it is running.",
18
19
  )
19
20
  process.exit(1)
20
21
  }
21
22
  }
22
23
 
24
+ function imageExistsLocally(image) {
25
+ const res = spawnSync("docker", ["image", "inspect", image], { stdio: "ignore" })
26
+ return res.status === 0
27
+ }
28
+
23
29
  function tryPullImage(image) {
24
30
  const res = spawnSync("docker", ["pull", image], { stdio: "inherit" })
25
31
  return res.status === 0
26
32
  }
27
33
 
34
+ function ensureImageAvailable(image) {
35
+ // Accept a locally-built image (e.g. `platform-dev:test` from `docker build`)
36
+ // without attempting a registry pull — local is authoritative if present.
37
+ if (imageExistsLocally(image)) return true
38
+ return tryPullImage(image)
39
+ }
40
+
28
41
  async function run(args, { cwd }) {
29
42
  const manifest = loadManifest(cwd)
30
43
  const pluginId = manifest.id
44
+ const frontendEntry = manifest.frontend?.entry || "./frontend/src/index.tsx"
45
+ const frontendBundle = path.join(cwd, ".palette", "dist", "frontend.mjs")
31
46
 
32
47
  ensureDocker()
33
48
 
49
+ let frontendWatcher = null
50
+ if (manifest.frontend?.entry) {
51
+ console.log(`[palette] bundling frontend ${frontendEntry} → .palette/dist/frontend.mjs`)
52
+ try {
53
+ frontendWatcher = await watchFrontend(cwd, frontendEntry, frontendBundle)
54
+ } catch (err) {
55
+ console.error(
56
+ `[palette] could not start frontend bundler: ${
57
+ err instanceof Error ? err.message : String(err)
58
+ }`,
59
+ )
60
+ process.exit(1)
61
+ }
62
+ } else {
63
+ console.log("[palette] manifest has no frontend entry; skipping frontend bundler")
64
+ }
65
+
34
66
  console.log(`[palette] starting ${DEFAULT_IMAGE} via docker compose`)
35
67
  console.log(`[palette] mounting ${cwd} → /plugins/${pluginId}`)
36
68
  console.log(`[palette] frontend: http://localhost:${FRONTEND_PORT}/apps/${pluginId}`)
@@ -39,7 +71,8 @@ async function run(args, { cwd }) {
39
71
  // Pre-pull so we can give a useful error if the image isn't reachable
40
72
  // (common cause: maintainer hasn't pushed it yet, or `docker login ghcr.io`
41
73
  // missing). Without this, the `docker compose up` failure is cryptic.
42
- if (!tryPullImage(DEFAULT_IMAGE)) {
74
+ if (!ensureImageAvailable(DEFAULT_IMAGE)) {
75
+ if (frontendWatcher) await frontendWatcher.dispose()
43
76
  console.error(
44
77
  `\n[palette] could not pull ${DEFAULT_IMAGE}.\n` +
45
78
  ` Most common causes:\n` +
@@ -70,10 +103,12 @@ async function run(args, { cwd }) {
70
103
  ["compose", "-f", COMPOSE_FILE, "-p", projectName, "down"],
71
104
  { stdio: "inherit", env },
72
105
  )
106
+ if (frontendWatcher) frontendWatcher.dispose().catch(() => {})
73
107
  }
74
108
  process.on("SIGINT", stop)
75
109
  process.on("SIGTERM", stop)
76
110
  await new Promise((resolve) => child.on("close", resolve))
111
+ if (frontendWatcher) await frontendWatcher.dispose()
77
112
  }
78
113
 
79
114
  module.exports = run
@@ -0,0 +1,143 @@
1
+ "use strict"
2
+
3
+ const fs = require("fs")
4
+ const net = require("net")
5
+ const path = require("path")
6
+ const { spawnSync } = require("child_process")
7
+ const { loadManifest, validateManifest } = require("../manifest")
8
+ const { bundleFrontend } = require("../bundler")
9
+
10
+ const DEFAULT_IMAGE =
11
+ process.env.PALETTE_DEV_IMAGE || "ghcr.io/palette-lab/platform-dev:latest"
12
+ const FRONTEND_PORT = Number(process.env.PALETTE_FRONTEND_PORT || "3000")
13
+ const BACKEND_PORT = Number(process.env.PALETTE_BACKEND_PORT || "8000")
14
+
15
+ function ok(message) {
16
+ console.log(`OK ${message}`)
17
+ }
18
+
19
+ function warn(message, fix) {
20
+ console.log(`WARN ${message}`)
21
+ if (fix) console.log(`FIX ${fix}`)
22
+ }
23
+
24
+ function fail(message, fix) {
25
+ console.log(`FAIL ${message}`)
26
+ if (fix) console.log(`FIX ${fix}`)
27
+ return 1
28
+ }
29
+
30
+ function nodeMajor() {
31
+ return Number(process.versions.node.split(".")[0])
32
+ }
33
+
34
+ function dockerRunning() {
35
+ return spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0
36
+ }
37
+
38
+ function imageExistsLocally(image) {
39
+ return spawnSync("docker", ["image", "inspect", image], { stdio: "ignore" }).status === 0
40
+ }
41
+
42
+ function canBindPort(port) {
43
+ return new Promise((resolve) => {
44
+ const server = net.createServer()
45
+ server.once("error", () => resolve(false))
46
+ server.once("listening", () => {
47
+ server.close(() => resolve(true))
48
+ })
49
+ server.listen(port, "127.0.0.1")
50
+ })
51
+ }
52
+
53
+ function checkEntry(cwd, label, rel) {
54
+ if (!rel) return 0
55
+ const abs = path.resolve(cwd, rel)
56
+ if (!fs.existsSync(abs)) {
57
+ return fail(`${label} entry not found: ${rel}`)
58
+ }
59
+ ok(`${label} entry exists: ${rel}`)
60
+ return 0
61
+ }
62
+
63
+ async function run(args, { cwd }) {
64
+ let failures = 0
65
+
66
+ if (nodeMajor() >= 18) ok(`Node ${process.versions.node}`)
67
+ else failures += fail("Node 18+ is required", "Install Node.js 18 or newer.")
68
+
69
+ if (dockerRunning()) ok("Docker is running")
70
+ else failures += fail(
71
+ "Docker is not running",
72
+ "Start Docker Desktop, then rerun pltt doctor.",
73
+ )
74
+
75
+ if (imageExistsLocally(DEFAULT_IMAGE)) {
76
+ ok(`platform image is available locally: ${DEFAULT_IMAGE}`)
77
+ } else {
78
+ warn(
79
+ `platform image is not present locally: ${DEFAULT_IMAGE}`,
80
+ "pltt dev will try to pull it. If that fails, run docker login ghcr.io or set PALETTE_DEV_IMAGE.",
81
+ )
82
+ }
83
+
84
+ const frontendPortFree = await canBindPort(FRONTEND_PORT)
85
+ if (frontendPortFree) ok(`frontend port ${FRONTEND_PORT} is available`)
86
+ else failures += fail(
87
+ `frontend port ${FRONTEND_PORT} is already in use`,
88
+ `Run PALETTE_FRONTEND_PORT=${FRONTEND_PORT + 1} pltt dev`,
89
+ )
90
+
91
+ const backendPortFree = await canBindPort(BACKEND_PORT)
92
+ if (backendPortFree) ok(`backend port ${BACKEND_PORT} is available`)
93
+ else failures += fail(
94
+ `backend port ${BACKEND_PORT} is already in use`,
95
+ `Run PALETTE_BACKEND_PORT=${BACKEND_PORT + 1} pltt dev`,
96
+ )
97
+
98
+ let manifest
99
+ try {
100
+ manifest = loadManifest(cwd)
101
+ ok("palette-plugin.json found")
102
+ } catch (err) {
103
+ failures += fail(
104
+ err instanceof Error ? err.message : String(err),
105
+ "Run this command from a Palette plugin root or create one with pltt init <name>.",
106
+ )
107
+ }
108
+
109
+ if (manifest) {
110
+ const errors = validateManifest(manifest)
111
+ if (errors.length) {
112
+ for (const err of errors) failures += fail(`manifest invalid: ${err}`)
113
+ } else {
114
+ ok(`manifest valid: ${manifest.id}@${manifest.version}`)
115
+ }
116
+
117
+ failures += checkEntry(cwd, "frontend", manifest.frontend?.entry)
118
+ failures += checkEntry(cwd, "backend", manifest.backend?.entry)
119
+ for (const tool of manifest.tools || []) {
120
+ failures += checkEntry(cwd, `tool[${tool.name}]`, tool.entry)
121
+ }
122
+
123
+ if (manifest.frontend?.entry) {
124
+ try {
125
+ const bundle = await bundleFrontend(cwd, manifest.frontend.entry)
126
+ ok(`frontend bundles successfully (${bundle.length} bytes)`)
127
+ } catch (err) {
128
+ failures += fail(
129
+ `frontend bundle failed: ${err instanceof Error ? err.message : String(err)}`,
130
+ "Install plugin dependencies and fix frontend compile errors, then rerun pltt doctor.",
131
+ )
132
+ }
133
+ }
134
+ }
135
+
136
+ if (failures > 0) {
137
+ console.log(`\n[palette] doctor found ${failures} blocking issue(s).`)
138
+ process.exit(1)
139
+ }
140
+ console.log("\n[palette] doctor passed.")
141
+ }
142
+
143
+ module.exports = run