novaui-cli 1.1.1 → 1.1.3

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 CHANGED
@@ -224,6 +224,117 @@ NovaUI components use CSS variables for theming. Customize colors, spacing, and
224
224
  - 🐛 [GitHub Issues](https://github.com/KaloyanBehov/novaui/issues) — Report bugs or request features
225
225
  - 💬 [GitHub Discussions](https://github.com/KaloyanBehov/novaui/discussions) — Ask questions
226
226
 
227
+ ## For Maintainers
228
+
229
+ ### Publishing Process
230
+
231
+ The CLI is standalone and can be published independently to npm.
232
+
233
+ #### Pre-publish Checklist
234
+
235
+ 1. **Validate registry**:
236
+ ```bash
237
+ pnpm validate:registry
238
+ ```
239
+
240
+ 2. **Run tests**:
241
+ ```bash
242
+ cd apps/cli
243
+ pnpm test
244
+ ```
245
+
246
+ 3. **Ensure no workspace dependencies**:
247
+ ```bash
248
+ cat package.json | grep "workspace:"
249
+ ```
250
+ Should return nothing.
251
+
252
+ #### Publishing
253
+
254
+ ```bash
255
+ cd apps/cli
256
+
257
+ # Bump version (patch/minor/major)
258
+ npm version patch
259
+
260
+ # The prepublishOnly script automatically:
261
+ # - Syncs themes from packages/themes/
262
+ # - Validates CLI is standalone
263
+ # Note: Registry is fetched from GitHub at runtime (not bundled)
264
+
265
+ # Publish to npm
266
+ npm publish
267
+
268
+ # Tag and push
269
+ git tag cli-v1.1.3
270
+ git push origin cli-v1.1.3
271
+ ```
272
+
273
+ #### What's Included in the Published Package
274
+
275
+ The CLI package includes:
276
+ - `src/**/*.js` - All CLI source code
277
+ - `src/themes/` - Theme CSS files (synced from packages/)
278
+ - `README.md` - This documentation
279
+
280
+ **Fetched at runtime** (not bundled):
281
+ - Component registry from GitHub (`packages/registry/registry.json`)
282
+ - Component source files from GitHub (`packages/components/src/ui/`)
283
+
284
+ **Not included** (via `.npmignore`):
285
+ - `__tests__/` - Test files
286
+ - `node_modules/` - Dependencies
287
+ - Development configs
288
+
289
+ #### Prebuild Script
290
+
291
+ The `scripts/prebuild.js` runs automatically before publish:
292
+
293
+ 1. Copies theme files from packages/themes/
294
+ 2. Validates no workspace dependencies remain
295
+ 3. Ensures all required files exist
296
+
297
+ If validation fails, publish is aborted.
298
+
299
+ #### Architecture: Runtime Fetch (like shadcn/ui)
300
+
301
+ The CLI fetches components and registry from GitHub at runtime:
302
+
303
+ **Benefits:**
304
+ - ✅ Users always get latest components without updating CLI
305
+ - ✅ Smaller package size (no bundled JSON)
306
+ - ✅ Can support multiple versions via branches/tags
307
+
308
+ **Environment Variables:**
309
+ ```bash
310
+ # Override GitHub branch (default: main)
311
+ NOVAUI_BRANCH=dev novaui-cli add button
312
+
313
+ # Useful for testing or using beta components
314
+ NOVAUI_BRANCH=beta novaui-cli add card
315
+ ```
316
+
317
+ ### Version Compatibility
318
+
319
+ The CLI checks for updates automatically (once per 24 hours) and warns users if they're running an outdated version.
320
+
321
+ Version format: `MAJOR.MINOR.PATCH`
322
+ - **Major**: Breaking changes
323
+ - **Minor**: New features, new components
324
+ - **Patch**: Bug fixes, improvements
325
+
326
+ ### Registry Sync
327
+
328
+ The registry must be synced before every CLI publish:
329
+
330
+ ```bash
331
+ # Manual sync (done automatically by prepublishOnly)
332
+ cd apps/cli
333
+ node scripts/prebuild.js
334
+ ```
335
+
336
+ This ensures the CLI always has the latest component metadata.
337
+
227
338
  ## License
228
339
 
229
340
  MIT
package/package.json CHANGED
@@ -1,30 +1,34 @@
1
1
  {
2
2
  "name": "novaui-cli",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "CLI for NovaUI - React Native component library with NativeWind",
5
5
  "type": "module",
6
6
  "bin": {
7
- "novaui": "./src/bin.js"
7
+ "novaui-cli": "./src/bin.js"
8
8
  },
9
9
  "files": [
10
- "src",
10
+ "src/**/*.js",
11
11
  "!src/__tests__",
12
12
  "README.md"
13
13
  ],
14
14
  "keywords": [
15
15
  "novaui",
16
+ "novaui-cli",
16
17
  "react-native",
17
- "cli",
18
+ "react native",
18
19
  "nativewind",
19
- "ui-components"
20
+ "tailwind",
21
+ "cli",
22
+ "command-line",
23
+ "component-library",
24
+ "ui-library",
25
+ "design-system",
26
+ "react-native-ui",
27
+ "mobile",
28
+ "expo",
29
+ "components",
30
+ "styling"
20
31
  ],
21
- "scripts": {
22
- "test": "vitest run",
23
- "test:watch": "vitest"
24
- },
25
- "devDependencies": {
26
- "vitest": "^3.0.0"
27
- },
28
32
  "license": "MIT",
29
33
  "repository": {
30
34
  "type": "git",
@@ -33,5 +37,28 @@
33
37
  "homepage": "https://github.com/KaloyanBehov/novaui#readme",
34
38
  "bugs": {
35
39
  "url": "https://github.com/KaloyanBehov/novaui/issues"
40
+ },
41
+ "dependencies": {
42
+ "@clack/prompts": "^1.0.1",
43
+ "class-variance-authority": "^0.7.1",
44
+ "clsx": "^2.1.1",
45
+ "commander": "^14.0.3",
46
+ "nativewind": "^4.2.2",
47
+ "ora": "^9.3.0",
48
+ "picocolors": "^1.1.1",
49
+ "react-native-reanimated": "~4.1.1",
50
+ "react-native-safe-area-context": "~5.6.0",
51
+ "tailwind-merge": "^3.5.0",
52
+ "zod": "^4.3.6"
53
+ },
54
+ "devDependencies": {
55
+ "babel-preset-expo": "^54.0.10",
56
+ "prettier-plugin-tailwindcss": "^0.5.11",
57
+ "tailwindcss": "^3.4.17",
58
+ "vitest": "^3.0.0"
59
+ },
60
+ "scripts": {
61
+ "test": "vitest run",
62
+ "test:watch": "vitest"
36
63
  }
37
- }
64
+ }
@@ -0,0 +1,491 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import os from "node:os"
5
+
6
+ import {
7
+ DEFAULT_CONFIG,
8
+ CONFIG_FILENAME,
9
+ GLOBAL_CSS_CONTENT,
10
+ UTILS_CONTENT,
11
+ getTailwindConfigContent,
12
+ loadConfig,
13
+ writeConfig,
14
+ init,
15
+ add,
16
+ } from "../bin.js"
17
+
18
+ // ─── Test helpers ───────────────────────────────────────────────────────────
19
+
20
+ function makeTmpDir() {
21
+ return fs.mkdtempSync(path.join(os.tmpdir(), "novaui-cmd-test-"))
22
+ }
23
+
24
+ function cleanTmpDir(dir) {
25
+ fs.rmSync(dir, { recursive: true, force: true })
26
+ }
27
+
28
+ // ─── init command ───────────────────────────────────────────────────────────
29
+
30
+ describe("init command", () => {
31
+ let tmp
32
+ let originalCwd
33
+
34
+ beforeEach(() => {
35
+ tmp = makeTmpDir()
36
+ originalCwd = process.cwd()
37
+ process.chdir(tmp)
38
+
39
+ // Create a minimal package.json so getMissingDeps works
40
+ fs.writeFileSync(
41
+ path.join(tmp, "package.json"),
42
+ JSON.stringify({
43
+ name: "test-project",
44
+ dependencies: {
45
+ nativewind: "^4.0.0",
46
+ tailwindcss: "^3.0.0",
47
+ clsx: "^2.0.0",
48
+ "tailwind-merge": "^3.0.0",
49
+ "class-variance-authority": "^0.7.0",
50
+ },
51
+ }),
52
+ "utf8"
53
+ )
54
+ })
55
+
56
+ afterEach(() => {
57
+ process.chdir(originalCwd)
58
+ cleanTmpDir(tmp)
59
+ })
60
+
61
+ it("creates components.json with default config", async () => {
62
+ // Non-TTY stdin means `ask()` returns defaults automatically
63
+ await init()
64
+
65
+ const configPath = path.join(tmp, CONFIG_FILENAME)
66
+ expect(fs.existsSync(configPath)).toBe(true)
67
+
68
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"))
69
+ expect(config.globalCss).toBe(DEFAULT_CONFIG.globalCss)
70
+ expect(config.componentsUi).toBe(DEFAULT_CONFIG.componentsUi)
71
+ expect(config.lib).toBe(DEFAULT_CONFIG.lib)
72
+ expect(config.theme).toBe(DEFAULT_CONFIG.theme)
73
+ })
74
+
75
+ it("creates utils.ts in the lib directory", async () => {
76
+ await init()
77
+
78
+ const utilsPath = path.join(tmp, DEFAULT_CONFIG.lib, "utils.ts")
79
+ expect(fs.existsSync(utilsPath)).toBe(true)
80
+
81
+ const content = fs.readFileSync(utilsPath, "utf8")
82
+ expect(content).toContain("export function cn")
83
+ expect(content).toBe(UTILS_CONTENT)
84
+ })
85
+
86
+ it("creates global.css", async () => {
87
+ await init()
88
+
89
+ const cssPath = path.join(tmp, DEFAULT_CONFIG.globalCss)
90
+ expect(fs.existsSync(cssPath)).toBe(true)
91
+
92
+ const content = fs.readFileSync(cssPath, "utf8")
93
+ expect(content).toContain("@tailwind base")
94
+ expect(content).toContain("--primary:")
95
+ expect(content).toContain(".dark")
96
+ expect(content).toBe(GLOBAL_CSS_CONTENT)
97
+ })
98
+
99
+ it("creates tailwind.config.js with correct theme", async () => {
100
+ await init()
101
+
102
+ const twPath = path.join(tmp, "tailwind.config.js")
103
+ expect(fs.existsSync(twPath)).toBe(true)
104
+
105
+ const content = fs.readFileSync(twPath, "utf8")
106
+ expect(content).toContain("module.exports")
107
+ expect(content).toContain('require("nativewind/preset")')
108
+ expect(content).toContain("hsl(var(--primary))")
109
+ expect(content).toContain("hsl(var(--background))")
110
+ expect(content).toContain("hsl(var(--border))")
111
+ expect(content).toContain("var(--radius)")
112
+ })
113
+
114
+ it("does not overwrite existing files on second run", async () => {
115
+ await init()
116
+
117
+ // Modify utils.ts
118
+ const utilsPath = path.join(tmp, DEFAULT_CONFIG.lib, "utils.ts")
119
+ fs.writeFileSync(utilsPath, "// custom content")
120
+
121
+ await init()
122
+
123
+ // Should still have custom content
124
+ expect(fs.readFileSync(utilsPath, "utf8")).toBe("// custom content")
125
+ })
126
+
127
+ it("creates all expected directories", async () => {
128
+ await init()
129
+
130
+ expect(fs.existsSync(path.join(tmp, path.dirname(DEFAULT_CONFIG.globalCss)))).toBe(true)
131
+ expect(fs.existsSync(path.join(tmp, DEFAULT_CONFIG.lib))).toBe(true)
132
+ })
133
+
134
+ it("preserves existing components.json on re-init (non-TTY defaults to no)", async () => {
135
+ const customConfig = {
136
+ globalCss: "app/styles.css",
137
+ componentsUi: "app/ui",
138
+ lib: "app/utils",
139
+ }
140
+ writeConfig(tmp, customConfig)
141
+
142
+ await init()
143
+
144
+ const loaded = loadConfig(tmp)
145
+ expect(loaded.globalCss).toBe("app/styles.css")
146
+ expect(loaded.componentsUi).toBe("app/ui")
147
+ expect(loaded.lib).toBe("app/utils")
148
+ })
149
+ })
150
+
151
+ // ─── add command ────────────────────────────────────────────────────────────
152
+
153
+ describe("add command", () => {
154
+ let tmp
155
+ let originalCwd
156
+ let originalFetch
157
+
158
+ beforeEach(() => {
159
+ tmp = makeTmpDir()
160
+ originalCwd = process.cwd()
161
+ process.chdir(tmp)
162
+ originalFetch = globalThis.fetch
163
+
164
+ // Create package.json
165
+ fs.writeFileSync(
166
+ path.join(tmp, "package.json"),
167
+ JSON.stringify({ name: "test-project", dependencies: {} }),
168
+ "utf8"
169
+ )
170
+ })
171
+
172
+ afterEach(() => {
173
+ process.chdir(originalCwd)
174
+ globalThis.fetch = originalFetch
175
+ cleanTmpDir(tmp)
176
+ })
177
+
178
+ it("creates utils.ts if missing", async () => {
179
+ // Mock fetch for registry and component file
180
+ globalThis.fetch = vi.fn(async (url) => {
181
+ if (url.endsWith("registry.json")) {
182
+ return {
183
+ ok: true,
184
+ json: async () => ({
185
+ button: {
186
+ files: ["src/components/ui/button.tsx"],
187
+ dependencies: [],
188
+ },
189
+ }),
190
+ }
191
+ }
192
+ if (url.endsWith("button.tsx")) {
193
+ return {
194
+ ok: true,
195
+ text: async () => 'export function Button() { return null }',
196
+ }
197
+ }
198
+ return { ok: false, statusText: "Not Found" }
199
+ })
200
+
201
+ await add("button")
202
+
203
+ const utilsPath = path.join(tmp, DEFAULT_CONFIG.lib, "utils.ts")
204
+ expect(fs.existsSync(utilsPath)).toBe(true)
205
+ expect(fs.readFileSync(utilsPath, "utf8")).toBe(UTILS_CONTENT)
206
+ })
207
+
208
+ it("downloads component file to the correct directory", async () => {
209
+ const componentCode = `import React from "react"\nexport function Button() { return null }`
210
+
211
+ globalThis.fetch = vi.fn(async (url) => {
212
+ if (url.endsWith("registry.json")) {
213
+ return {
214
+ ok: true,
215
+ json: async () => ({
216
+ button: {
217
+ files: ["src/components/ui/button.tsx"],
218
+ dependencies: [],
219
+ },
220
+ }),
221
+ }
222
+ }
223
+ if (url.endsWith("button.tsx")) {
224
+ return { ok: true, text: async () => componentCode }
225
+ }
226
+ return { ok: false, statusText: "Not Found" }
227
+ })
228
+
229
+ await add("button")
230
+
231
+ const dest = path.join(tmp, DEFAULT_CONFIG.componentsUi, "button.tsx")
232
+ expect(fs.existsSync(dest)).toBe(true)
233
+ expect(fs.readFileSync(dest, "utf8")).toBe(componentCode)
234
+ })
235
+
236
+ it("uses paths from components.json if present", async () => {
237
+ const customConfig = {
238
+ globalCss: "app/global.css",
239
+ componentsUi: "app/ui",
240
+ lib: "app/lib",
241
+ }
242
+ writeConfig(tmp, customConfig)
243
+
244
+ globalThis.fetch = vi.fn(async (url) => {
245
+ if (url.endsWith("registry.json")) {
246
+ return {
247
+ ok: true,
248
+ json: async () => ({
249
+ card: { files: ["src/components/ui/card.tsx"], dependencies: [] },
250
+ }),
251
+ }
252
+ }
253
+ if (url.endsWith("card.tsx")) {
254
+ return { ok: true, text: async () => "export function Card() {}" }
255
+ }
256
+ return { ok: false, statusText: "Not Found" }
257
+ })
258
+
259
+ await add("card")
260
+
261
+ const dest = path.join(tmp, "app", "ui", "card.tsx")
262
+ expect(fs.existsSync(dest)).toBe(true)
263
+ })
264
+
265
+ it("throws when registry fetch fails", async () => {
266
+ globalThis.fetch = vi.fn(async () => ({
267
+ ok: false,
268
+ statusText: "Internal Server Error",
269
+ }))
270
+
271
+ await expect(add("button")).rejects.toThrow(/Failed to fetch registry/)
272
+ })
273
+
274
+ it("throws when registry is not a valid object", async () => {
275
+ globalThis.fetch = vi.fn(async () => ({
276
+ ok: true,
277
+ json: async () => [1, 2, 3],
278
+ }))
279
+
280
+ await expect(add("button")).rejects.toThrow(/not a valid object/)
281
+ })
282
+
283
+ it("handles component with multiple files", async () => {
284
+ globalThis.fetch = vi.fn(async (url) => {
285
+ if (url.endsWith("registry.json")) {
286
+ return {
287
+ ok: true,
288
+ json: async () => ({
289
+ dialog: {
290
+ files: [
291
+ "src/components/ui/dialog.tsx",
292
+ "src/components/ui/dialog-overlay.tsx",
293
+ ],
294
+ dependencies: [],
295
+ },
296
+ }),
297
+ }
298
+ }
299
+ if (url.endsWith("dialog.tsx")) {
300
+ return { ok: true, text: async () => "// dialog" }
301
+ }
302
+ if (url.endsWith("dialog-overlay.tsx")) {
303
+ return { ok: true, text: async () => "// overlay" }
304
+ }
305
+ return { ok: false, statusText: "Not Found" }
306
+ })
307
+
308
+ await add("dialog")
309
+
310
+ const uiDir = path.join(tmp, DEFAULT_CONFIG.componentsUi)
311
+ expect(fs.existsSync(path.join(uiDir, "dialog.tsx"))).toBe(true)
312
+ expect(fs.existsSync(path.join(uiDir, "dialog-overlay.tsx"))).toBe(true)
313
+ })
314
+
315
+ it("continues when a single file download fails", async () => {
316
+ globalThis.fetch = vi.fn(async (url) => {
317
+ if (url.endsWith("registry.json")) {
318
+ return {
319
+ ok: true,
320
+ json: async () => ({
321
+ multi: {
322
+ files: ["src/components/ui/a.tsx", "src/components/ui/b.tsx"],
323
+ dependencies: [],
324
+ },
325
+ }),
326
+ }
327
+ }
328
+ if (url.endsWith("a.tsx")) {
329
+ return { ok: false, statusText: "Not Found" }
330
+ }
331
+ if (url.endsWith("b.tsx")) {
332
+ return { ok: true, text: async () => "// b component" }
333
+ }
334
+ return { ok: false, statusText: "Not Found" }
335
+ })
336
+
337
+ await add("multi")
338
+
339
+ const uiDir = path.join(tmp, DEFAULT_CONFIG.componentsUi)
340
+ expect(fs.existsSync(path.join(uiDir, "a.tsx"))).toBe(false)
341
+ expect(fs.existsSync(path.join(uiDir, "b.tsx"))).toBe(true)
342
+ })
343
+
344
+ it("throws when component is not found in registry", async () => {
345
+ globalThis.fetch = vi.fn(async (url) => {
346
+ if (url.endsWith("registry.json")) {
347
+ return {
348
+ ok: true,
349
+ json: async () => ({
350
+ button: {
351
+ files: ["src/components/ui/button.tsx"],
352
+ dependencies: [],
353
+ },
354
+ }),
355
+ }
356
+ }
357
+ return { ok: false, statusText: "Not Found" }
358
+ })
359
+
360
+ await expect(add("nonexistent")).rejects.toThrow(/not found in registry/)
361
+ })
362
+
363
+ it("throws when no component name is provided", async () => {
364
+ await expect(add()).rejects.toThrow(/Missing component name/)
365
+ await expect(add("")).rejects.toThrow(/Missing component name/)
366
+ })
367
+
368
+ it("skips dependency install when all deps are already present", async () => {
369
+ fs.writeFileSync(
370
+ path.join(tmp, "package.json"),
371
+ JSON.stringify({
372
+ name: "test-project",
373
+ dependencies: { "lucide-react-native": "^1.0.0" },
374
+ }),
375
+ "utf8"
376
+ )
377
+
378
+ globalThis.fetch = vi.fn(async (url) => {
379
+ if (url.endsWith("registry.json")) {
380
+ return {
381
+ ok: true,
382
+ json: async () => ({
383
+ icon: {
384
+ files: ["src/components/ui/icon.tsx"],
385
+ dependencies: ["lucide-react-native"],
386
+ },
387
+ }),
388
+ }
389
+ }
390
+ if (url.endsWith("icon.tsx")) {
391
+ return { ok: true, text: async () => "// icon component" }
392
+ }
393
+ return { ok: false, statusText: "Not Found" }
394
+ })
395
+
396
+ await add("icon")
397
+
398
+ const dest = path.join(tmp, DEFAULT_CONFIG.componentsUi, "icon.tsx")
399
+ expect(fs.existsSync(dest)).toBe(true)
400
+ })
401
+
402
+ it("does not overwrite existing component files by default", async () => {
403
+ const uiDir = path.join(tmp, DEFAULT_CONFIG.componentsUi)
404
+ fs.mkdirSync(uiDir, { recursive: true })
405
+ fs.writeFileSync(path.join(uiDir, "button.tsx"), "// custom code")
406
+
407
+ globalThis.fetch = vi.fn(async (url) => {
408
+ if (url.endsWith("registry.json")) {
409
+ return {
410
+ ok: true,
411
+ json: async () => ({
412
+ button: {
413
+ files: ["src/components/ui/button.tsx"],
414
+ dependencies: [],
415
+ },
416
+ }),
417
+ }
418
+ }
419
+ if (url.endsWith("button.tsx")) {
420
+ return { ok: true, text: async () => "// new code from registry" }
421
+ }
422
+ return { ok: false, statusText: "Not Found" }
423
+ })
424
+
425
+ await add("button")
426
+
427
+ const content = fs.readFileSync(path.join(uiDir, "button.tsx"), "utf8")
428
+ expect(content).toBe("// custom code")
429
+ })
430
+
431
+ it("overwrites existing component files with force option", async () => {
432
+ const uiDir = path.join(tmp, DEFAULT_CONFIG.componentsUi)
433
+ fs.mkdirSync(uiDir, { recursive: true })
434
+ fs.writeFileSync(path.join(uiDir, "button.tsx"), "// custom code")
435
+
436
+ globalThis.fetch = vi.fn(async (url) => {
437
+ if (url.endsWith("registry.json")) {
438
+ return {
439
+ ok: true,
440
+ json: async () => ({
441
+ button: {
442
+ files: ["src/components/ui/button.tsx"],
443
+ dependencies: [],
444
+ },
445
+ }),
446
+ }
447
+ }
448
+ if (url.endsWith("button.tsx")) {
449
+ return { ok: true, text: async () => "// new code from registry" }
450
+ }
451
+ return { ok: false, statusText: "Not Found" }
452
+ })
453
+
454
+ await add("button", { force: true })
455
+
456
+ const content = fs.readFileSync(path.join(uiDir, "button.tsx"), "utf8")
457
+ expect(content).toBe("// new code from registry")
458
+ })
459
+
460
+ it("does not create utils.ts if it already exists", async () => {
461
+ const utilsDir = path.join(tmp, DEFAULT_CONFIG.lib)
462
+ fs.mkdirSync(utilsDir, { recursive: true })
463
+ fs.writeFileSync(path.join(utilsDir, "utils.ts"), "// custom utils")
464
+
465
+ globalThis.fetch = vi.fn(async (url) => {
466
+ if (url.endsWith("registry.json")) {
467
+ return {
468
+ ok: true,
469
+ json: async () => ({
470
+ card: {
471
+ files: ["src/components/ui/card.tsx"],
472
+ dependencies: [],
473
+ },
474
+ }),
475
+ }
476
+ }
477
+ if (url.endsWith("card.tsx")) {
478
+ return { ok: true, text: async () => "// card" }
479
+ }
480
+ return { ok: false, statusText: "Not Found" }
481
+ })
482
+
483
+ await add("card")
484
+
485
+ const utilsContent = fs.readFileSync(
486
+ path.join(utilsDir, "utils.ts"),
487
+ "utf8"
488
+ )
489
+ expect(utilsContent).toBe("// custom utils")
490
+ })
491
+ })