plankui 0.0.0-stage → 0.1.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/README.md +12 -2
- package/index.mjs +272 -0
- package/package.json +32 -4
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plankui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Add [PlankUI](https://plankui.com) components to a React 19 + Tailwind CSS v4 project.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx plankui@latest init
|
|
7
|
+
npx plankui@latest add button select dialog
|
|
8
|
+
npx plankui@latest list
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`init` writes `tokens.css`, `styles.css` and the `cn` helper and installs the shared packages.
|
|
12
|
+
`add` copies each component plus every PlankUI file it builds on, and installs their npm packages.
|
|
13
|
+
Add `--yes` in CI or for coding agents; it accepts the defaults and keeps files that already exist.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// plankui: copies PlankUI components into your project and installs what they need.
|
|
3
|
+
// Zero dependencies. Reads the registry from the PlankUI site.
|
|
4
|
+
//
|
|
5
|
+
// npx plankui@latest init
|
|
6
|
+
// npx plankui@latest add button select dialog
|
|
7
|
+
// npx plankui@latest list
|
|
8
|
+
// npx plankui@latest login YOUR_LICENSE_KEY
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join, relative } from "node:path";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
|
|
15
|
+
const VERSION = "0.1.0";
|
|
16
|
+
const DEFAULT_REGISTRY = "https://plankui.com/r";
|
|
17
|
+
|
|
18
|
+
/* ── Arguments ── */
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const flags = new Set(argv.filter((a) => a.startsWith("--") && !a.includes("=")));
|
|
21
|
+
const opt = (name, fallback) => {
|
|
22
|
+
const hit = argv.find((a) => a.startsWith(`--${name}=`));
|
|
23
|
+
if (hit) return hit.split("=").slice(1).join("=");
|
|
24
|
+
const i = argv.indexOf(`--${name}`);
|
|
25
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : fallback;
|
|
26
|
+
};
|
|
27
|
+
const valued = new Set(["--cwd", "--registry"]);
|
|
28
|
+
const positional = argv.filter((a, i) => !a.startsWith("--") && !valued.has(argv[i - 1]));
|
|
29
|
+
const [command, ...names] = positional;
|
|
30
|
+
const yes = flags.has("--yes") || flags.has("-y");
|
|
31
|
+
const overwrite = flags.has("--overwrite");
|
|
32
|
+
const skipInstall = flags.has("--no-install");
|
|
33
|
+
const cwd = opt("cwd", process.cwd());
|
|
34
|
+
const registry = (opt("registry", process.env.PLANKUI_REGISTRY) ?? DEFAULT_REGISTRY).replace(/\/$/, "");
|
|
35
|
+
|
|
36
|
+
/* ── Output ── */
|
|
37
|
+
const tty = process.stdout.isTTY;
|
|
38
|
+
const paint = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
39
|
+
const dim = paint(2), bold = paint(1), green = paint(32), yellow = paint(33), red = paint(31);
|
|
40
|
+
const say = (s = "") => console.log(s);
|
|
41
|
+
const fail = (msg) => {
|
|
42
|
+
console.error(`${red("error")} ${msg}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Ask a yes/no question. With --yes, takes the default. With no terminal and no --yes, stops instead of guessing. */
|
|
47
|
+
async function confirm(question, fallback) {
|
|
48
|
+
if (yes) return fallback;
|
|
49
|
+
if (!process.stdin.isTTY) fail(`${question}\n No terminal to answer in. Run again with --yes to accept the defaults.`);
|
|
50
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
51
|
+
const answer = (await rl.question(`${question} ${dim(fallback ? "(Y/n)" : "(y/N)")} `)).trim().toLowerCase();
|
|
52
|
+
rl.close();
|
|
53
|
+
return answer ? answer.startsWith("y") : fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ── Project ── */
|
|
57
|
+
function project() {
|
|
58
|
+
const pkgPath = join(cwd, "package.json");
|
|
59
|
+
if (!existsSync(pkgPath)) fail(`No package.json in ${cwd}. Run this inside your React project.`);
|
|
60
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
61
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
62
|
+
const base = existsSync(join(cwd, "src")) ? join(cwd, "src") : cwd;
|
|
63
|
+
const pm = existsSync(join(cwd, "pnpm-lock.yaml")) ? "pnpm"
|
|
64
|
+
: existsSync(join(cwd, "yarn.lock")) ? "yarn"
|
|
65
|
+
: existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock")) ? "bun"
|
|
66
|
+
: "npm";
|
|
67
|
+
return { pkg, deps, base, pm };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function install(pm, packages, haveDeps) {
|
|
71
|
+
const missing = [...new Set(packages)].filter((p) => !haveDeps[p]);
|
|
72
|
+
if (!missing.length) return;
|
|
73
|
+
if (skipInstall) {
|
|
74
|
+
say(`${yellow("skip")} install ${missing.join(" ")} ${dim("(--no-install)")}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const verb = pm === "npm" ? "install" : "add";
|
|
78
|
+
say(`${dim("installing")} ${missing.join(" ")}`);
|
|
79
|
+
const run = spawnSync(pm, [verb, ...missing], { cwd, stdio: "inherit", shell: process.platform === "win32" });
|
|
80
|
+
if (run.status !== 0) fail(`${pm} ${verb} failed. Install these yourself: ${missing.join(" ")}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ── Licence ── */
|
|
84
|
+
const configFile = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "plankui", "config.json");
|
|
85
|
+
function licenceKey() {
|
|
86
|
+
if (process.env.PLANKUI_LICENSE_KEY) return process.env.PLANKUI_LICENSE_KEY.trim();
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(readFileSync(configFile, "utf8")).licenseKey || null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function login() {
|
|
95
|
+
const key = names[0]?.trim();
|
|
96
|
+
if (!key) fail(`Pass the key from your purchase email: ${green("npx plankui@latest login YOUR_LICENSE_KEY")}`);
|
|
97
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
98
|
+
writeFileSync(configFile, JSON.stringify({ licenseKey: key }, null, 2) + "\n", { mode: 0o600 });
|
|
99
|
+
say(`${green("saved")} your licence key to ${configFile}`);
|
|
100
|
+
say(`Add Pro components the same way as free ones: ${green("npx plankui@latest add <name>")}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function logout() {
|
|
104
|
+
rmSync(configFile, { force: true });
|
|
105
|
+
say(`${green("removed")} the saved licence key.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Pro sources come from /r/pro/<name>.json, which answers only with a valid licence key. */
|
|
109
|
+
async function fetchPro(name, key) {
|
|
110
|
+
let res;
|
|
111
|
+
try {
|
|
112
|
+
res = await fetch(`${registry}/pro/${name}.json`, { headers: { authorization: `Bearer ${key}` } });
|
|
113
|
+
} catch {
|
|
114
|
+
fail(`Could not reach ${registry}. Check your connection, or pass --registry.`);
|
|
115
|
+
}
|
|
116
|
+
if (res.status === 401 || res.status === 403) {
|
|
117
|
+
fail(`The registry refused your licence key. Check it against your purchase email and run ${green("npx plankui@latest login <key>")} again.`);
|
|
118
|
+
}
|
|
119
|
+
let body = null;
|
|
120
|
+
try {
|
|
121
|
+
body = JSON.parse(await res.text());
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
if (res.ok) return body;
|
|
126
|
+
if (res.status !== 404 && body?.error) fail(body.error);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function fetchJson(name) {
|
|
131
|
+
const url = `${registry}/${name}.json`;
|
|
132
|
+
let res;
|
|
133
|
+
try {
|
|
134
|
+
res = await fetch(url);
|
|
135
|
+
} catch {
|
|
136
|
+
fail(`Could not reach ${registry}. Check your connection, or pass --registry.`);
|
|
137
|
+
}
|
|
138
|
+
if (res.status === 404) return null;
|
|
139
|
+
if (!res.ok) fail(`${url} answered ${res.status}.`);
|
|
140
|
+
// Static hosts answer unknown paths with the site's HTML page, so anything that is not JSON means "no such item".
|
|
141
|
+
const text = await res.text();
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(text);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Write files under the project base, keeping existing ones unless asked. Returns what happened per file. */
|
|
150
|
+
async function writeFiles(base, files) {
|
|
151
|
+
const done = [];
|
|
152
|
+
for (const f of files) {
|
|
153
|
+
const target = join(base, f.path);
|
|
154
|
+
const shown = relative(cwd, target);
|
|
155
|
+
if (existsSync(target) && !overwrite) {
|
|
156
|
+
const same = readFileSync(target, "utf8") === f.content;
|
|
157
|
+
if (same) {
|
|
158
|
+
done.push([dim("same"), shown]);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!(await confirm(`${shown} already exists and differs. Replace it?`, false))) {
|
|
162
|
+
done.push([yellow("kept"), shown]);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
167
|
+
writeFileSync(target, f.content);
|
|
168
|
+
done.push([green("wrote"), shown]);
|
|
169
|
+
}
|
|
170
|
+
return done;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ── Commands ── */
|
|
174
|
+
async function init() {
|
|
175
|
+
const { deps, base, pm } = project();
|
|
176
|
+
say(bold("PlankUI") + dim(` init, into ${relative(cwd, base) || "."}`));
|
|
177
|
+
if (!deps.tailwindcss) say(`${yellow("note")} Tailwind CSS v4 is not in package.json. PlankUI needs it: see the framework guides.`);
|
|
178
|
+
if (!deps.react) fail("React is not in package.json. PlankUI is for React 19 projects.");
|
|
179
|
+
const spec = await fetchJson("init");
|
|
180
|
+
if (!spec) fail("The registry has no init file.");
|
|
181
|
+
for (const [state, file] of await writeFiles(base, spec.files)) say(`${state} ${file}`);
|
|
182
|
+
install(pm, spec.dependencies, deps);
|
|
183
|
+
const css = relative(cwd, join(base, "styles.css"));
|
|
184
|
+
say();
|
|
185
|
+
say(bold("Two lines left to add:"));
|
|
186
|
+
say(` 1. Import ${css} once in your entry file, for example: ${green(`import "./styles.css";`)}`);
|
|
187
|
+
say(` 2. Map ${green("@/*")} to ${relative(cwd, base) ? `${relative(cwd, base)}/*` : "./*"} in tsconfig "paths" and in your bundler's alias.`);
|
|
188
|
+
say();
|
|
189
|
+
say(`Then add components: ${green("npx plankui@latest add button")}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function add() {
|
|
193
|
+
if (!names.length) fail("Name at least one component, for example: npx plankui@latest add button select");
|
|
194
|
+
const { deps, base, pm } = project();
|
|
195
|
+
if (!existsSync(join(base, "lib", "cn.ts"))) {
|
|
196
|
+
say(`${yellow("note")} lib/cn.ts is missing. Run ${green("npx plankui@latest init")} first.`);
|
|
197
|
+
if (!(await confirm("Add components anyway?", true))) process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
// Resolve every PlankUI file the requested components build on.
|
|
200
|
+
const queue = [...names.map((n) => n.toLowerCase())];
|
|
201
|
+
const seen = new Map();
|
|
202
|
+
while (queue.length) {
|
|
203
|
+
const name = queue.shift();
|
|
204
|
+
if (seen.has(name)) continue;
|
|
205
|
+
let item = await fetchJson(name);
|
|
206
|
+
if (!item) fail(`No component called "${name}". Run ${green("npx plankui@latest list")} to see them all.`);
|
|
207
|
+
const key = item.pro ? licenceKey() : null;
|
|
208
|
+
if (key) item = (await fetchPro(name, key)) ?? item;
|
|
209
|
+
seen.set(name, item);
|
|
210
|
+
queue.push(...item.registryDependencies);
|
|
211
|
+
}
|
|
212
|
+
const items = [...seen.values()];
|
|
213
|
+
const locked = items.filter((i) => i.pro && !i.files?.length);
|
|
214
|
+
const free = items.filter((i) => i.files?.length);
|
|
215
|
+
if (locked.length) {
|
|
216
|
+
const what = `${locked.map((i) => i.title).join(", ")} ${locked.length === 1 ? "is" : "are"} part of PlankUI Pro.`;
|
|
217
|
+
if (licenceKey()) say(`${yellow("pro")} ${what} The registry did not send ${locked.length === 1 ? "it" : "them"} for your key yet. Write to us if this keeps happening.`);
|
|
218
|
+
else say(`${yellow("pro")} ${what} Activate your licence with ${green("npx plankui@latest login <key>")}, or get Pro at ${registry.replace(/\/r$/, "")}/pricing`);
|
|
219
|
+
}
|
|
220
|
+
if (!free.length) process.exit(locked.length ? 1 : 0);
|
|
221
|
+
const extra = free.filter((i) => !names.includes(i.name)).map((i) => i.name);
|
|
222
|
+
say(bold("PlankUI") + dim(` add ${free.map((i) => i.name).join(", ")}`) + (extra.length ? dim(` (needed by your picks: ${extra.join(", ")})`) : ""));
|
|
223
|
+
for (const [state, file] of await writeFiles(base, free.flatMap((i) => i.files))) say(`${state} ${file}`);
|
|
224
|
+
install(pm, free.flatMap((i) => i.dependencies), deps);
|
|
225
|
+
say();
|
|
226
|
+
const first = free.find((i) => names.includes(i.name)) ?? free[0];
|
|
227
|
+
const pascal = first.name.replace(/(^|-)(\w)/g, (_, __, c) => c.toUpperCase());
|
|
228
|
+
say(`Use it: ${green(`import { ${pascal} } from "@/components/ui/${first.name}";`)}`);
|
|
229
|
+
if (locked.length) process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function list() {
|
|
233
|
+
const index = await fetchJson("index");
|
|
234
|
+
if (!index) fail("The registry has no index.");
|
|
235
|
+
let category = "";
|
|
236
|
+
for (const c of index) {
|
|
237
|
+
if (c.category !== category) {
|
|
238
|
+
category = c.category;
|
|
239
|
+
say();
|
|
240
|
+
say(bold(category));
|
|
241
|
+
}
|
|
242
|
+
say(` ${c.name.padEnd(24)}${c.pro ? yellow("pro ") : " "}${dim(c.description)}`);
|
|
243
|
+
}
|
|
244
|
+
say();
|
|
245
|
+
say(`${index.length} components. Add one with ${green("npx plankui@latest add <name>")}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const help = () => {
|
|
249
|
+
say(`${bold("plankui")} ${VERSION}: add PlankUI components to your React project.
|
|
250
|
+
|
|
251
|
+
${bold("Commands")}
|
|
252
|
+
init Write tokens.css, styles.css and the cn helper, install shared packages
|
|
253
|
+
add <names...> Copy components and every PlankUI file they build on, install their packages
|
|
254
|
+
list Show every component
|
|
255
|
+
login <key> Save your PlankUI Pro licence key (once per machine)
|
|
256
|
+
logout Forget the saved licence key
|
|
257
|
+
|
|
258
|
+
${bold("Options")}
|
|
259
|
+
--yes, -y Answer every prompt with its default (keeps files that already exist)
|
|
260
|
+
--overwrite Replace files that already exist
|
|
261
|
+
--no-install Write files but skip installing npm packages
|
|
262
|
+
--cwd <dir> Run in another folder
|
|
263
|
+
--registry <url> Read components from another registry`);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (flags.has("--version") || flags.has("-v")) say(VERSION);
|
|
267
|
+
else if (command === "init") await init();
|
|
268
|
+
else if (command === "add") await add();
|
|
269
|
+
else if (command === "list") await list();
|
|
270
|
+
else if (command === "login") await login();
|
|
271
|
+
else if (command === "logout") logout();
|
|
272
|
+
else help();
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plankui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Add PlankUI components to your React and Tailwind CSS v4 project.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"plankui": "index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.mjs",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react",
|
|
18
|
+
"tailwindcss",
|
|
19
|
+
"radix",
|
|
20
|
+
"components",
|
|
21
|
+
"ui",
|
|
22
|
+
"plankui"
|
|
23
|
+
],
|
|
24
|
+
"homepage": "https://plankui.com",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/VectorLabsHQ/plankui.git",
|
|
29
|
+
"directory": "cli"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/VectorLabsHQ/plankui/issues"
|
|
33
|
+
}
|
|
34
|
+
}
|