@palettelab/cli 0.1.0 → 0.3.0

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/lib/cli.js CHANGED
@@ -26,10 +26,16 @@ function printHelp() {
26
26
  for (const [name, { help }] of Object.entries(COMMANDS)) {
27
27
  console.log(` ${name.padEnd(8)} ${help}`)
28
28
  }
29
+ 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")
29
32
  console.log("\nExamples:")
30
- console.log(" npx @palettelab/cli init my-app")
31
- console.log(" cd my-app && npx @palettelab/cli dev")
32
- console.log(" npx @palettelab/cli build")
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")
33
39
  }
34
40
 
35
41
  async function run(argv) {
@@ -20,6 +20,11 @@ function ensureDocker() {
20
20
  }
21
21
  }
22
22
 
23
+ function tryPullImage(image) {
24
+ const res = spawnSync("docker", ["pull", image], { stdio: "inherit" })
25
+ return res.status === 0
26
+ }
27
+
23
28
  async function run(args, { cwd }) {
24
29
  const manifest = loadManifest(cwd)
25
30
  const pluginId = manifest.id
@@ -31,6 +36,21 @@ async function run(args, { cwd }) {
31
36
  console.log(`[palette] frontend: http://localhost:${FRONTEND_PORT}/apps/${pluginId}`)
32
37
  console.log(`[palette] backend: http://localhost:${BACKEND_PORT}/api/v1/plugins/${pluginId}`)
33
38
 
39
+ // Pre-pull so we can give a useful error if the image isn't reachable
40
+ // (common cause: maintainer hasn't pushed it yet, or `docker login ghcr.io`
41
+ // missing). Without this, the `docker compose up` failure is cryptic.
42
+ if (!tryPullImage(DEFAULT_IMAGE)) {
43
+ console.error(
44
+ `\n[palette] could not pull ${DEFAULT_IMAGE}.\n` +
45
+ ` Most common causes:\n` +
46
+ ` • The image hasn't been published to the registry yet.\n` +
47
+ ` Platform maintainers: see docker/platform-dev/README.md for the build + push flow.\n` +
48
+ ` • You are not logged in: docker login ghcr.io -u <github-user> -p <pat>\n` +
49
+ ` • You are pointing at a different tag: set PALETTE_DEV_IMAGE=<your-image> to override.\n`,
50
+ )
51
+ process.exit(1)
52
+ }
53
+
34
54
  const env = {
35
55
  ...process.env,
36
56
  PALETTE_DEV_IMAGE: DEFAULT_IMAGE,
@@ -3,43 +3,35 @@
3
3
  const crypto = require("crypto")
4
4
  const { loadManifest, validateManifest } = require("../manifest")
5
5
  const { bundleFrontend, bundleBackend } = require("../bundler")
6
-
7
- const DEFAULT_PLATFORM_URL =
8
- process.env.PALETTE_PLATFORM_URL || "https://platform.palette-lab.internal"
6
+ const {
7
+ resolveEnvironment,
8
+ parseFlags,
9
+ confirmProduction,
10
+ } = require("../environments")
9
11
 
10
12
  function sha256(buf) {
11
13
  return crypto.createHash("sha256").update(buf).digest("hex")
12
14
  }
13
15
 
14
- function token() {
15
- const t = process.env.PALETTE_PUBLISH_TOKEN
16
- if (!t) {
17
- console.error(
18
- "[palette] PALETTE_PUBLISH_TOKEN is required. Ask a superadmin to mint one for you via\n" +
19
- " POST /api/superadmin/publish-tokens — then `export PALETTE_PUBLISH_TOKEN=<token>`.",
20
- )
21
- process.exit(1)
22
- }
23
- return t
24
- }
25
-
26
- async function api(pathname, { method = "GET", body, headers = {} } = {}) {
27
- const url = `${DEFAULT_PLATFORM_URL}${pathname}`
28
- const res = await fetch(url, {
29
- method,
30
- headers: {
31
- Authorization: `Bearer ${token()}`,
32
- "Content-Type": "application/json",
33
- ...headers,
34
- },
35
- body: body ? JSON.stringify(body) : undefined,
36
- })
37
- if (!res.ok) {
38
- const text = await res.text()
39
- throw new Error(`${method} ${pathname} → ${res.status}: ${text}`)
16
+ function makeApi(env) {
17
+ return async function api(pathname, { method = "GET", body, headers = {} } = {}) {
18
+ const url = `${env.url}${pathname}`
19
+ const res = await fetch(url, {
20
+ method,
21
+ headers: {
22
+ Authorization: `Bearer ${env.token}`,
23
+ "Content-Type": "application/json",
24
+ ...headers,
25
+ },
26
+ body: body ? JSON.stringify(body) : undefined,
27
+ })
28
+ if (!res.ok) {
29
+ const text = await res.text()
30
+ throw new Error(`${method} ${pathname} ${res.status}: ${text}`)
31
+ }
32
+ const ct = res.headers.get("content-type") || ""
33
+ return ct.includes("application/json") ? res.json() : res.text()
40
34
  }
41
- const ct = res.headers.get("content-type") || ""
42
- return ct.includes("application/json") ? res.json() : res.text()
43
35
  }
44
36
 
45
37
  async function put(url, buf, contentType) {
@@ -53,7 +45,34 @@ async function put(url, buf, contentType) {
53
45
  }
54
46
  }
55
47
 
56
- async function run(args, { cwd }) {
48
+ async function run(argv, { cwd }) {
49
+ const { flags } = parseFlags(argv)
50
+
51
+ let env
52
+ try {
53
+ env = resolveEnvironment({ cwd, flags })
54
+ } catch (err) {
55
+ console.error(`[palette] ${err.message}`)
56
+ process.exit(1)
57
+ }
58
+
59
+ if (!env.token) {
60
+ console.error(
61
+ `[palette] no publish token for environment "${env.name}". ` +
62
+ `Set $${env.token_env} (or $PALETTE_PUBLISH_TOKEN as fallback).\n` +
63
+ ` Ask a superadmin to mint one via POST ${env.url}/api/superadmin/publish-tokens.`,
64
+ )
65
+ process.exit(1)
66
+ }
67
+
68
+ if (env.production_unconfirmed) {
69
+ const ok = await confirmProduction(env)
70
+ if (!ok) {
71
+ console.error("[palette] publish cancelled.")
72
+ process.exit(1)
73
+ }
74
+ }
75
+
57
76
  const manifest = loadManifest(cwd)
58
77
  const errors = validateManifest(manifest)
59
78
  if (errors.length) {
@@ -62,7 +81,9 @@ async function run(args, { cwd }) {
62
81
  process.exit(1)
63
82
  }
64
83
 
65
- console.log(`[palette] publishing ${manifest.id}@${manifest.version}`)
84
+ console.log(
85
+ `[palette] publishing ${manifest.id}@${manifest.version} → ${env.name} (${env.url})`,
86
+ )
66
87
 
67
88
  console.log("[palette] bundling frontend")
68
89
  const frontend = await bundleFrontend(cwd, manifest.frontend?.entry || "./frontend/src/index.tsx")
@@ -73,6 +94,7 @@ async function run(args, { cwd }) {
73
94
  console.log(`[palette] ${backend.length} bytes`)
74
95
 
75
96
  const backendSha = sha256(backend)
97
+ const api = makeApi(env)
76
98
 
77
99
  console.log("[palette] requesting signed URLs")
78
100
  const signed = await api("/api/v1/appstore/sign-upload", {
@@ -107,8 +129,11 @@ async function run(args, { cwd }) {
107
129
  },
108
130
  })
109
131
 
110
- console.log(`[palette] published ${record.plugin_id}@${record.version}`)
111
- console.log(`[palette] live at ${DEFAULT_PLATFORM_URL}${record.catalog_url}`)
132
+ console.log(
133
+ `[palette] published ${record.plugin_id}@${record.version} (status=${record.status})`,
134
+ )
135
+ console.log(`[palette] awaiting superadmin review on ${env.url}`)
136
+ console.log(`[palette] once approved, live at ${env.url}${record.catalog_url}`)
112
137
  }
113
138
 
114
139
  module.exports = run
@@ -0,0 +1,165 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * Named environments for `palette publish`.
5
+ *
6
+ * Config is discovered in this order (first hit wins):
7
+ * 1. ./palette.config.json (plugin-repo-local override)
8
+ * 2. ~/.palette/config.json (per-user)
9
+ * 3. Built-in defaults (see DEFAULTS below)
10
+ *
11
+ * CLI env selection:
12
+ * 1. `--env <name>` flag
13
+ * 2. `PALETTE_ENV` env var
14
+ * 3. `default_environment` from config
15
+ * 4. "local"
16
+ *
17
+ * Each environment resolves to a platform URL and a publish token:
18
+ * url — absolute base URL of the target platform (no trailing slash)
19
+ * token_env — name of the env var holding the PALETTE_PUBLISH_TOKEN for this env
20
+ * production — boolean; requires `--env production` flag AND interactive
21
+ * confirmation (or `--yes` to skip). Keeps accidental prod
22
+ * pushes from happening.
23
+ */
24
+
25
+ const fs = require("fs")
26
+ const os = require("os")
27
+ const path = require("path")
28
+
29
+ const DEFAULTS = {
30
+ environments: {
31
+ local: {
32
+ url: "http://localhost:8000",
33
+ token_env: "PALETTE_LOCAL_TOKEN",
34
+ production: false,
35
+ },
36
+ staging: {
37
+ url: process.env.PALETTE_STAGING_URL || "",
38
+ token_env: "PALETTE_STAGING_TOKEN",
39
+ production: false,
40
+ },
41
+ production: {
42
+ url: process.env.PALETTE_PRODUCTION_URL || "",
43
+ token_env: "PALETTE_PROD_TOKEN",
44
+ production: true,
45
+ },
46
+ },
47
+ default_environment: "local",
48
+ }
49
+
50
+ function readJsonIfExists(p) {
51
+ try {
52
+ if (!fs.existsSync(p)) return null
53
+ return JSON.parse(fs.readFileSync(p, "utf8"))
54
+ } catch (err) {
55
+ console.error(`[palette] failed to parse ${p}: ${err.message}`)
56
+ return null
57
+ }
58
+ }
59
+
60
+ function loadConfig(cwd) {
61
+ const repoCfg = readJsonIfExists(path.join(cwd, "palette.config.json"))
62
+ if (repoCfg) return { ...DEFAULTS, ...repoCfg, environments: { ...DEFAULTS.environments, ...(repoCfg.environments || {}) } }
63
+ const userCfg = readJsonIfExists(path.join(os.homedir(), ".palette", "config.json"))
64
+ if (userCfg) return { ...DEFAULTS, ...userCfg, environments: { ...DEFAULTS.environments, ...(userCfg.environments || {}) } }
65
+ return DEFAULTS
66
+ }
67
+
68
+ /**
69
+ * Resolve the effective environment.
70
+ *
71
+ * Returns { name, url, token, production, production_unconfirmed }:
72
+ * - name: resolved environment name
73
+ * - url: platform URL
74
+ * - token: publish token (may be "" if unset — caller decides whether to exit)
75
+ * - production: boolean from config
76
+ * - production_unconfirmed: true iff production AND --yes wasn't passed;
77
+ * caller is expected to prompt.
78
+ */
79
+ function resolveEnvironment({ cwd, flags }) {
80
+ const cfg = loadConfig(cwd)
81
+ const requested =
82
+ flags.env ||
83
+ process.env.PALETTE_ENV ||
84
+ cfg.default_environment ||
85
+ "local"
86
+
87
+ const envCfg = cfg.environments[requested]
88
+ if (!envCfg) {
89
+ const available = Object.keys(cfg.environments).join(", ")
90
+ throw new Error(
91
+ `unknown environment "${requested}". Available: ${available}. ` +
92
+ `Configure via ./palette.config.json or ~/.palette/config.json.`,
93
+ )
94
+ }
95
+
96
+ // Backward-compat: PALETTE_PLATFORM_URL / PALETTE_PUBLISH_TOKEN override
97
+ // per-env resolution when the user hasn't picked an env explicitly.
98
+ const useLegacy =
99
+ !flags.env &&
100
+ !process.env.PALETTE_ENV &&
101
+ process.env.PALETTE_PLATFORM_URL
102
+
103
+ const url = useLegacy
104
+ ? process.env.PALETTE_PLATFORM_URL
105
+ : envCfg.url
106
+
107
+ const token = useLegacy
108
+ ? process.env.PALETTE_PUBLISH_TOKEN || ""
109
+ : process.env[envCfg.token_env] || process.env.PALETTE_PUBLISH_TOKEN || ""
110
+
111
+ if (!url) {
112
+ throw new Error(
113
+ `environment "${requested}" has no url configured. Set it in ` +
114
+ `~/.palette/config.json or set PALETTE_${requested.toUpperCase()}_URL.`,
115
+ )
116
+ }
117
+
118
+ return {
119
+ name: requested,
120
+ url: url.replace(/\/+$/, ""),
121
+ token,
122
+ production: Boolean(envCfg.production),
123
+ production_unconfirmed: Boolean(envCfg.production) && !flags.yes,
124
+ token_env: envCfg.token_env,
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Minimal arg parser — extracts `--env <name>`, `--yes`, `-y` from argv.
130
+ * Leaves positional args in `rest`.
131
+ */
132
+ function parseFlags(argv) {
133
+ const flags = { env: undefined, yes: false }
134
+ const rest = []
135
+ for (let i = 0; i < argv.length; i++) {
136
+ const a = argv[i]
137
+ if (a === "--env" || a === "-e") {
138
+ flags.env = argv[++i]
139
+ } else if (a.startsWith("--env=")) {
140
+ flags.env = a.slice("--env=".length)
141
+ } else if (a === "--yes" || a === "-y") {
142
+ flags.yes = true
143
+ } else {
144
+ rest.push(a)
145
+ }
146
+ }
147
+ return { flags, rest }
148
+ }
149
+
150
+ async function confirmProduction(env) {
151
+ return new Promise((resolve) => {
152
+ const readline = require("readline")
153
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
154
+ rl.question(
155
+ `\n[palette] You are about to publish to PRODUCTION (${env.url}).\n` +
156
+ ` Type the environment name ("${env.name}") to confirm, anything else cancels: `,
157
+ (answer) => {
158
+ rl.close()
159
+ resolve(answer.trim() === env.name)
160
+ },
161
+ )
162
+ })
163
+ }
164
+
165
+ module.exports = { resolveEnvironment, parseFlags, confirmProduction, loadConfig }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "palette": "./bin/palette.js"
@@ -10,6 +10,7 @@
10
10
  "lib",
11
11
  "platform-dev",
12
12
  "template-fallback",
13
+ "palette.config.example.json",
13
14
  "README.md"
14
15
  ],
15
16
  "engines": {
@@ -0,0 +1,21 @@
1
+ {
2
+ "_comment": "Copy to ./palette.config.json (per-plugin) or ~/.palette/config.json (per-user). Delete this comment line and entries you don't need.",
3
+ "default_environment": "local",
4
+ "environments": {
5
+ "local": {
6
+ "url": "http://localhost:8000",
7
+ "token_env": "PALETTE_LOCAL_TOKEN",
8
+ "production": false
9
+ },
10
+ "staging": {
11
+ "url": "https://staging.yourcompany.example",
12
+ "token_env": "PALETTE_STAGING_TOKEN",
13
+ "production": false
14
+ },
15
+ "production": {
16
+ "url": "https://app.yourcompany.example",
17
+ "token_env": "PALETTE_PROD_TOKEN",
18
+ "production": true
19
+ }
20
+ }
21
+ }