novaui-cli 1.0.7 → 1.0.8

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
@@ -1,35 +1,108 @@
1
1
  # novaui-cli
2
2
 
3
- The CLI for **NovaUI** -- a React Native UI component library with 50+ components built on [NativeWind](https://www.nativewind.dev/) (Tailwind CSS for React Native).
3
+ The CLI for **NovaUI** a React Native UI component library with 50+ components built on [NativeWind](https://www.nativewind.dev/) (Tailwind CSS for React Native).
4
4
 
5
- Inspired by [shadcn/ui](https://ui.shadcn.com), adapted for mobile. Components are copied directly into your project -- you own the code, customize anything.
5
+ Inspired by [shadcn/ui](https://ui.shadcn.com), adapted for mobile. Components are copied directly into your project you own the code, customize anything.
6
6
 
7
- ## Getting Started
7
+ ## Prerequisites
8
8
 
9
- ### Initialize your project
9
+ Before using NovaUI CLI, ensure you have:
10
+
11
+ - **React** >= 18
12
+ - **React Native** >= 0.72
13
+ - **NativeWind** >= 4 (must be set up first — see [NativeWind docs](https://www.nativewind.dev/docs/getting-started/installation))
14
+ - **Tailwind CSS** >= 3
15
+
16
+ > **⚠️ Important**: NovaUI requires NativeWind to be properly configured. If you haven't set up NativeWind yet, please follow the [NativeWind installation guide](https://www.nativewind.dev/docs/getting-started/installation) before proceeding.
17
+
18
+ ## Quick Start
19
+
20
+ ### Step 1: Set Up NativeWind
21
+
22
+ NovaUI requires NativeWind to be installed and configured. If you haven't done this yet:
23
+
24
+ 1. Install NativeWind and Tailwind CSS:
25
+ ```bash
26
+ npm install nativewind
27
+ npm install -D tailwindcss
28
+ ```
29
+
30
+ 2. Initialize Tailwind:
31
+ ```bash
32
+ npx tailwindcss init
33
+ ```
34
+
35
+ 3. Configure NativeWind in your `tailwind.config.js`:
36
+ ```js
37
+ module.exports = {
38
+ content: ["./App.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}"],
39
+ presets: [require("nativewind/preset")],
40
+ }
41
+ ```
42
+
43
+ 4. Add NativeWind to your Babel config (`babel.config.js`):
44
+ ```js
45
+ module.exports = {
46
+ plugins: ["nativewind/babel"],
47
+ }
48
+ ```
49
+
50
+ For complete setup instructions, visit the [NativeWind documentation](https://www.nativewind.dev/docs/getting-started/installation).
51
+
52
+ ### Step 2: Initialize NovaUI
53
+
54
+ Run the interactive setup command:
10
55
 
11
56
  ```bash
12
57
  npx novaui-cli init
13
58
  ```
14
59
 
15
- The interactive setup walks you through configuring file paths:
60
+ The CLI will prompt you to configure file paths:
16
61
 
17
62
  ```
18
- ? Path for global.css? (src/global.css)
19
- ? Path for UI components? (src/components/ui)
20
- ? Path for lib (utils)? (src/lib)
63
+ ? Path for global.css? (src/global.css)
64
+ ? Path for UI components? (src/components/ui)
65
+ ? Path for lib (utils)? (src/lib)
21
66
  ```
22
67
 
23
- This creates:
68
+ Press Enter to accept defaults or type custom paths.
69
+
70
+ #### What Gets Created
24
71
 
25
- - **`components.json`** -- stores your configured paths
26
- - **`tailwind.config.js`** -- NovaUI theme with colors, border radius, and NativeWind preset
27
- - **`global.css`** -- light and dark theme CSS variables
28
- - **`lib/utils.ts`** -- the `cn()` class merging utility
72
+ The `init` command creates:
29
73
 
30
- It also installs the required dependencies: `nativewind`, `tailwindcss`, `clsx`, `tailwind-merge`, `class-variance-authority`.
74
+ - **`components.json`** Stores your configured paths for future component additions
75
+ - **`tailwind.config.js`** — NovaUI theme configuration with colors, border radius, and NativeWind preset
76
+ - **`global.css`** — Light and dark theme CSS variables
77
+ - **`lib/utils.ts`** — The `cn()` utility function for merging Tailwind classes
78
+
79
+ #### Dependencies Installed
80
+
81
+ The CLI automatically installs:
82
+
83
+ - `nativewind` — Tailwind CSS for React Native
84
+ - `tailwindcss` — Tailwind CSS framework
85
+ - `clsx` — Utility for constructing className strings
86
+ - `tailwind-merge` — Utility to merge Tailwind CSS classes without conflicts
87
+ - `class-variance-authority` — Utility for creating type-safe component variants
88
+
89
+ ### Step 3: Import Global Styles
90
+
91
+ Import the `global.css` file in your root entry file (typically `App.tsx`):
92
+
93
+ ```tsx
94
+ import "./src/global.css"
95
+
96
+ export default function App() {
97
+ // Your app content
98
+ }
99
+ ```
31
100
 
32
- ### Add components
101
+ This ensures NovaUI's theme variables are available throughout your app.
102
+
103
+ ### Step 4: Add Components
104
+
105
+ Add components to your project:
33
106
 
34
107
  ```bash
35
108
  npx novaui-cli add button
@@ -39,7 +112,11 @@ npx novaui-cli add dialog
39
112
 
40
113
  Components are copied into your configured directory (default: `src/components/ui/`). Each component's dependencies are installed automatically.
41
114
 
42
- ### Use them
115
+ **Component naming**: Use the filename without extension — `button`, `card`, `alert-dialog`, `dropdown-menu`, etc.
116
+
117
+ ### Step 5: Use Components
118
+
119
+ Import and use components in your app:
43
120
 
44
121
  ```tsx
45
122
  import { Button } from "./src/components/ui/button"
@@ -61,34 +138,44 @@ export default function App() {
61
138
  }
62
139
  ```
63
140
 
64
- Don't forget to import your `global.css` in your root entry file (e.g. `App.tsx`):
65
-
66
- ```tsx
67
- import "./src/global.css"
68
- ```
69
-
70
- ## Commands
141
+ ## CLI Commands
71
142
 
72
143
  | Command | Description |
73
- |---|---|
74
- | `npx novaui-cli init` | Interactive setup -- config, Tailwind, global CSS, utils |
144
+ |---------|-------------|
145
+ | `npx novaui-cli init` | Interactive setup creates config, Tailwind config, global CSS, and utils |
75
146
  | `npx novaui-cli add <name>` | Add a component to your project |
76
147
  | `npx novaui-cli --version` | Show CLI version |
77
- | `npx novaui-cli --help` | Show help |
148
+ | `npx novaui-cli --help` | Show help and available commands |
78
149
 
79
150
  ## Available Components
80
151
 
81
- Accordion, Alert, Alert Dialog, Aspect Ratio, Avatar, Badge, Breadcrumb, Button, Button Group, Calendar, Card, Carousel, Chart, Checkbox, Collapsible, Combobox, Command, Context Menu, Dialog, Drawer, Dropdown Menu, Empty, Field, Hover Card, Input, Input Group, Input OTP, Item, Label, Menubar, Navigation Menu, Pagination, Popover, Progress, Radio Group, Resizable, Scroll Area, Select, Separator, Sheet, Skeleton, Spinner, Switch, Table, Tabs, Text, Textarea, Toggle, Toggle Group, Typography
152
+ NovaUI includes 50+ components ready to use:
153
+
154
+ **Layout & Structure**: Accordion, Alert, Alert Dialog, Aspect Ratio, Card, Collapsible, Dialog, Drawer, Separator, Sheet
155
+
156
+ **Navigation**: Breadcrumb, Command, Context Menu, Dropdown Menu, Menubar, Navigation Menu, Tabs
157
+
158
+ **Forms & Input**: Button, Button Group, Checkbox, Combobox, Field, Input, Input Group, Input OTP, Label, Radio Group, Select, Switch, Textarea, Toggle, Toggle Group
159
+
160
+ **Data Display**: Avatar, Badge, Calendar, Carousel, Chart, Empty, Pagination, Progress, Skeleton, Spinner, Table, Typography
161
+
162
+ **Overlays**: Hover Card, Popover, Resizable, Scroll Area
163
+
164
+ **Text**: Text
165
+
166
+ Add any component with:
82
167
 
83
168
  ```bash
84
- npx novaui-cli add <name>
169
+ npx novaui-cli add <component-name>
85
170
  ```
86
171
 
87
- Use the component filename without the extension: `button`, `card`, `alert-dialog`, `dropdown-menu`, etc.
172
+ **Component naming**: Use the filename without extension `button`, `card`, `alert-dialog`, `dropdown-menu`, etc.
88
173
 
89
174
  ## Configuration
90
175
 
91
- Running `init` creates a `components.json` in your project root:
176
+ ### Customizing Paths
177
+
178
+ Running `init` creates a `components.json` file in your project root:
92
179
 
93
180
  ```json
94
181
  {
@@ -98,20 +185,44 @@ Running `init` creates a `components.json` in your project root:
98
185
  }
99
186
  ```
100
187
 
101
- Edit this file or run `npx novaui-cli init` again to change paths.
188
+ To change these paths:
189
+ - Edit `components.json` directly, or
190
+ - Run `npx novaui-cli init` again to reconfigure
191
+
192
+ ### Customizing Themes
193
+
194
+ NovaUI components use CSS variables for theming. Customize colors, spacing, and design tokens in your `global.css` file. The generated `tailwind.config.js` includes a comprehensive theme configuration you can customize.
195
+
196
+ ## Troubleshooting
197
+
198
+ ### Components Not Styling Correctly
199
+
200
+ - ✅ Ensure `global.css` is imported in your root file
201
+ - ✅ Verify NativeWind is configured in `babel.config.js`
202
+ - ✅ Check that `tailwind.config.js` includes the NativeWind preset
203
+ - ✅ Ensure content paths in `tailwind.config.js` include your component directories
204
+
205
+ ### Build Issues
206
+
207
+ - Clear cache: `npx react-native start --reset-cache`
208
+ - Reinstall dependencies: `rm -rf node_modules && npm install`
209
+ - Verify all peer dependencies are installed
102
210
 
103
211
  ## Requirements
104
212
 
105
- | Package | Version |
106
- |---|---|
213
+ | Package | Minimum Version |
214
+ |---------|----------------|
107
215
  | react | >= 18 |
108
216
  | react-native | >= 0.72 |
109
217
  | nativewind | >= 4 |
110
218
  | tailwindcss | >= 3 |
111
219
 
112
- ## Links
220
+ ## Resources
113
221
 
114
- - [GitHub](https://github.com/KaloyanBehov/native-ui)
222
+ - 📖 [NovaUI Documentation](https://github.com/KaloyanBehov/novaui) — Full component documentation
223
+ - 🎨 [NativeWind Documentation](https://www.nativewind.dev/docs) — Learn more about NativeWind
224
+ - 🐛 [GitHub Issues](https://github.com/KaloyanBehov/novaui/issues) — Report bugs or request features
225
+ - 💬 [GitHub Discussions](https://github.com/KaloyanBehov/novaui/discussions) — Ask questions
115
226
 
116
227
  ## License
117
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novaui-cli",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "CLI for NovaUI - React Native component library with NativeWind",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,9 +17,20 @@
17
17
  "nativewind",
18
18
  "ui-components"
19
19
  ],
20
+ "scripts": {
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "devDependencies": {
25
+ "vitest": "^3.0.0"
26
+ },
20
27
  "license": "MIT",
21
28
  "repository": {
22
29
  "type": "git",
23
- "url": "https://github.com/KaloyanBehov/native-ui"
30
+ "url": "https://github.com/KaloyanBehov/novaui"
31
+ },
32
+ "homepage": "https://github.com/KaloyanBehov/novaui#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/KaloyanBehov/novaui/issues"
24
35
  }
25
36
  }
@@ -0,0 +1,342 @@
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
+ })
73
+
74
+ it("creates utils.ts in the lib directory", async () => {
75
+ await init()
76
+
77
+ const utilsPath = path.join(tmp, DEFAULT_CONFIG.lib, "utils.ts")
78
+ expect(fs.existsSync(utilsPath)).toBe(true)
79
+
80
+ const content = fs.readFileSync(utilsPath, "utf8")
81
+ expect(content).toContain("export function cn")
82
+ expect(content).toBe(UTILS_CONTENT)
83
+ })
84
+
85
+ it("creates global.css", async () => {
86
+ await init()
87
+
88
+ const cssPath = path.join(tmp, DEFAULT_CONFIG.globalCss)
89
+ expect(fs.existsSync(cssPath)).toBe(true)
90
+
91
+ const content = fs.readFileSync(cssPath, "utf8")
92
+ expect(content).toContain("@tailwind base")
93
+ expect(content).toContain("--primary:")
94
+ expect(content).toContain(".dark")
95
+ expect(content).toBe(GLOBAL_CSS_CONTENT)
96
+ })
97
+
98
+ it("creates tailwind.config.js with correct theme", async () => {
99
+ await init()
100
+
101
+ const twPath = path.join(tmp, "tailwind.config.js")
102
+ expect(fs.existsSync(twPath)).toBe(true)
103
+
104
+ const content = fs.readFileSync(twPath, "utf8")
105
+ expect(content).toContain("module.exports")
106
+ expect(content).toContain('require("nativewind/preset")')
107
+ expect(content).toContain("hsl(var(--primary))")
108
+ expect(content).toContain("hsl(var(--background))")
109
+ expect(content).toContain("hsl(var(--border))")
110
+ expect(content).toContain("var(--radius)")
111
+ })
112
+
113
+ it("does not overwrite existing files on second run", async () => {
114
+ await init()
115
+
116
+ // Modify utils.ts
117
+ const utilsPath = path.join(tmp, DEFAULT_CONFIG.lib, "utils.ts")
118
+ fs.writeFileSync(utilsPath, "// custom content")
119
+
120
+ await init()
121
+
122
+ // Should still have custom content
123
+ expect(fs.readFileSync(utilsPath, "utf8")).toBe("// custom content")
124
+ })
125
+
126
+ it("creates all expected directories", async () => {
127
+ await init()
128
+
129
+ expect(fs.existsSync(path.join(tmp, "src", "lib"))).toBe(true)
130
+ expect(fs.existsSync(path.join(tmp, "src"))).toBe(true)
131
+ })
132
+
133
+ it("preserves existing components.json on re-init (non-TTY defaults to no)", async () => {
134
+ const customConfig = {
135
+ globalCss: "app/styles.css",
136
+ componentsUi: "app/ui",
137
+ lib: "app/utils",
138
+ }
139
+ writeConfig(tmp, customConfig)
140
+
141
+ await init()
142
+
143
+ const loaded = loadConfig(tmp)
144
+ expect(loaded.globalCss).toBe("app/styles.css")
145
+ expect(loaded.componentsUi).toBe("app/ui")
146
+ expect(loaded.lib).toBe("app/utils")
147
+ })
148
+ })
149
+
150
+ // ─── add command ────────────────────────────────────────────────────────────
151
+
152
+ describe("add command", () => {
153
+ let tmp
154
+ let originalCwd
155
+ let originalFetch
156
+
157
+ beforeEach(() => {
158
+ tmp = makeTmpDir()
159
+ originalCwd = process.cwd()
160
+ process.chdir(tmp)
161
+ originalFetch = globalThis.fetch
162
+
163
+ // Create package.json
164
+ fs.writeFileSync(
165
+ path.join(tmp, "package.json"),
166
+ JSON.stringify({ name: "test-project", dependencies: {} }),
167
+ "utf8"
168
+ )
169
+ })
170
+
171
+ afterEach(() => {
172
+ process.chdir(originalCwd)
173
+ globalThis.fetch = originalFetch
174
+ cleanTmpDir(tmp)
175
+ })
176
+
177
+ it("creates utils.ts if missing", async () => {
178
+ // Mock fetch for registry and component file
179
+ globalThis.fetch = vi.fn(async (url) => {
180
+ if (url.endsWith("registry.json")) {
181
+ return {
182
+ ok: true,
183
+ json: async () => ({
184
+ button: {
185
+ files: ["src/components/ui/button.tsx"],
186
+ dependencies: [],
187
+ },
188
+ }),
189
+ }
190
+ }
191
+ if (url.endsWith("button.tsx")) {
192
+ return {
193
+ ok: true,
194
+ text: async () => 'export function Button() { return null }',
195
+ }
196
+ }
197
+ return { ok: false, statusText: "Not Found" }
198
+ })
199
+
200
+ await add("button")
201
+
202
+ const utilsPath = path.join(tmp, DEFAULT_CONFIG.lib, "utils.ts")
203
+ expect(fs.existsSync(utilsPath)).toBe(true)
204
+ expect(fs.readFileSync(utilsPath, "utf8")).toBe(UTILS_CONTENT)
205
+ })
206
+
207
+ it("downloads component file to the correct directory", async () => {
208
+ const componentCode = `import React from "react"\nexport function Button() { return null }`
209
+
210
+ globalThis.fetch = vi.fn(async (url) => {
211
+ if (url.endsWith("registry.json")) {
212
+ return {
213
+ ok: true,
214
+ json: async () => ({
215
+ button: {
216
+ files: ["src/components/ui/button.tsx"],
217
+ dependencies: [],
218
+ },
219
+ }),
220
+ }
221
+ }
222
+ if (url.endsWith("button.tsx")) {
223
+ return { ok: true, text: async () => componentCode }
224
+ }
225
+ return { ok: false, statusText: "Not Found" }
226
+ })
227
+
228
+ await add("button")
229
+
230
+ const dest = path.join(tmp, DEFAULT_CONFIG.componentsUi, "button.tsx")
231
+ expect(fs.existsSync(dest)).toBe(true)
232
+ expect(fs.readFileSync(dest, "utf8")).toBe(componentCode)
233
+ })
234
+
235
+ it("uses paths from components.json if present", async () => {
236
+ const customConfig = {
237
+ globalCss: "app/global.css",
238
+ componentsUi: "app/ui",
239
+ lib: "app/lib",
240
+ }
241
+ writeConfig(tmp, customConfig)
242
+
243
+ globalThis.fetch = vi.fn(async (url) => {
244
+ if (url.endsWith("registry.json")) {
245
+ return {
246
+ ok: true,
247
+ json: async () => ({
248
+ card: { files: ["src/components/ui/card.tsx"], dependencies: [] },
249
+ }),
250
+ }
251
+ }
252
+ if (url.endsWith("card.tsx")) {
253
+ return { ok: true, text: async () => "export function Card() {}" }
254
+ }
255
+ return { ok: false, statusText: "Not Found" }
256
+ })
257
+
258
+ await add("card")
259
+
260
+ const dest = path.join(tmp, "app", "ui", "card.tsx")
261
+ expect(fs.existsSync(dest)).toBe(true)
262
+ })
263
+
264
+ it("throws when registry fetch fails", async () => {
265
+ globalThis.fetch = vi.fn(async () => ({
266
+ ok: false,
267
+ statusText: "Internal Server Error",
268
+ }))
269
+
270
+ await expect(add("button")).rejects.toThrow(/Failed to fetch registry/)
271
+ })
272
+
273
+ it("throws when registry is not a valid object", async () => {
274
+ globalThis.fetch = vi.fn(async () => ({
275
+ ok: true,
276
+ json: async () => [1, 2, 3],
277
+ }))
278
+
279
+ await expect(add("button")).rejects.toThrow(/not a valid object/)
280
+ })
281
+
282
+ it("handles component with multiple files", async () => {
283
+ globalThis.fetch = vi.fn(async (url) => {
284
+ if (url.endsWith("registry.json")) {
285
+ return {
286
+ ok: true,
287
+ json: async () => ({
288
+ dialog: {
289
+ files: [
290
+ "src/components/ui/dialog.tsx",
291
+ "src/components/ui/dialog-overlay.tsx",
292
+ ],
293
+ dependencies: [],
294
+ },
295
+ }),
296
+ }
297
+ }
298
+ if (url.endsWith("dialog.tsx")) {
299
+ return { ok: true, text: async () => "// dialog" }
300
+ }
301
+ if (url.endsWith("dialog-overlay.tsx")) {
302
+ return { ok: true, text: async () => "// overlay" }
303
+ }
304
+ return { ok: false, statusText: "Not Found" }
305
+ })
306
+
307
+ await add("dialog")
308
+
309
+ const uiDir = path.join(tmp, DEFAULT_CONFIG.componentsUi)
310
+ expect(fs.existsSync(path.join(uiDir, "dialog.tsx"))).toBe(true)
311
+ expect(fs.existsSync(path.join(uiDir, "dialog-overlay.tsx"))).toBe(true)
312
+ })
313
+
314
+ it("continues when a single file download fails", async () => {
315
+ globalThis.fetch = vi.fn(async (url) => {
316
+ if (url.endsWith("registry.json")) {
317
+ return {
318
+ ok: true,
319
+ json: async () => ({
320
+ multi: {
321
+ files: ["src/components/ui/a.tsx", "src/components/ui/b.tsx"],
322
+ dependencies: [],
323
+ },
324
+ }),
325
+ }
326
+ }
327
+ if (url.endsWith("a.tsx")) {
328
+ return { ok: false, statusText: "Not Found" }
329
+ }
330
+ if (url.endsWith("b.tsx")) {
331
+ return { ok: true, text: async () => "// b component" }
332
+ }
333
+ return { ok: false, statusText: "Not Found" }
334
+ })
335
+
336
+ await add("multi")
337
+
338
+ const uiDir = path.join(tmp, DEFAULT_CONFIG.componentsUi)
339
+ expect(fs.existsSync(path.join(uiDir, "a.tsx"))).toBe(false)
340
+ expect(fs.existsSync(path.join(uiDir, "b.tsx"))).toBe(true)
341
+ })
342
+ })
@@ -0,0 +1,372 @@
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
+ detectPackageManager,
13
+ ensureDir,
14
+ writeIfNotExists,
15
+ getMissingDeps,
16
+ loadConfig,
17
+ writeConfig,
18
+ assertValidComponentConfig,
19
+ formatError,
20
+ } from "../bin.js"
21
+
22
+ // ─── Test helpers ───────────────────────────────────────────────────────────
23
+
24
+ function makeTmpDir() {
25
+ return fs.mkdtempSync(path.join(os.tmpdir(), "novaui-test-"))
26
+ }
27
+
28
+ function cleanTmpDir(dir) {
29
+ fs.rmSync(dir, { recursive: true, force: true })
30
+ }
31
+
32
+ // ─── DEFAULT_CONFIG ─────────────────────────────────────────────────────────
33
+
34
+ describe("DEFAULT_CONFIG", () => {
35
+ it("has the expected default paths", () => {
36
+ expect(DEFAULT_CONFIG).toEqual({
37
+ globalCss: "src/global.css",
38
+ componentsUi: "src/components/ui",
39
+ lib: "src/lib",
40
+ })
41
+ })
42
+ })
43
+
44
+ // ─── detectPackageManager ───────────────────────────────────────────────────
45
+
46
+ describe("detectPackageManager", () => {
47
+ const originalAgent = process.env.npm_config_user_agent
48
+
49
+ afterEach(() => {
50
+ if (originalAgent !== undefined) {
51
+ process.env.npm_config_user_agent = originalAgent
52
+ } else {
53
+ delete process.env.npm_config_user_agent
54
+ }
55
+ })
56
+
57
+ it("returns npm by default", () => {
58
+ process.env.npm_config_user_agent = ""
59
+ const result = detectPackageManager()
60
+ expect(result.command).toBe("npm")
61
+ expect(result.baseArgs).toEqual(["install"])
62
+ })
63
+
64
+ it("detects yarn", () => {
65
+ process.env.npm_config_user_agent = "yarn/1.22.0 npm/? node/v18.0.0"
66
+ const result = detectPackageManager()
67
+ expect(result.command).toBe("yarn")
68
+ expect(result.baseArgs).toEqual(["add"])
69
+ })
70
+
71
+ it("detects pnpm", () => {
72
+ process.env.npm_config_user_agent = "pnpm/8.0.0 npm/? node/v18.0.0"
73
+ const result = detectPackageManager()
74
+ expect(result.command).toBe("pnpm")
75
+ expect(result.baseArgs).toEqual(["add"])
76
+ })
77
+
78
+ it("detects bun", () => {
79
+ process.env.npm_config_user_agent = "bun/1.0.0"
80
+ const result = detectPackageManager()
81
+ expect(result.command).toBe("bun")
82
+ expect(result.baseArgs).toEqual(["add"])
83
+ })
84
+ })
85
+
86
+ // ─── ensureDir ──────────────────────────────────────────────────────────────
87
+
88
+ describe("ensureDir", () => {
89
+ let tmp
90
+
91
+ beforeEach(() => {
92
+ tmp = makeTmpDir()
93
+ })
94
+
95
+ afterEach(() => {
96
+ cleanTmpDir(tmp)
97
+ })
98
+
99
+ it("creates a directory that does not exist", () => {
100
+ const dir = path.join(tmp, "a", "b", "c")
101
+ expect(fs.existsSync(dir)).toBe(false)
102
+ ensureDir(dir)
103
+ expect(fs.existsSync(dir)).toBe(true)
104
+ expect(fs.statSync(dir).isDirectory()).toBe(true)
105
+ })
106
+
107
+ it("does nothing if directory already exists", () => {
108
+ const dir = path.join(tmp, "existing")
109
+ fs.mkdirSync(dir)
110
+ ensureDir(dir)
111
+ expect(fs.existsSync(dir)).toBe(true)
112
+ })
113
+ })
114
+
115
+ // ─── writeIfNotExists ───────────────────────────────────────────────────────
116
+
117
+ describe("writeIfNotExists", () => {
118
+ let tmp
119
+
120
+ beforeEach(() => {
121
+ tmp = makeTmpDir()
122
+ })
123
+
124
+ afterEach(() => {
125
+ cleanTmpDir(tmp)
126
+ })
127
+
128
+ it("creates a file when it does not exist", () => {
129
+ const filePath = path.join(tmp, "test.txt")
130
+ const result = writeIfNotExists(filePath, "hello", "test.txt")
131
+ expect(result).toBe(true)
132
+ expect(fs.readFileSync(filePath, "utf8")).toBe("hello")
133
+ })
134
+
135
+ it("does not overwrite an existing file", () => {
136
+ const filePath = path.join(tmp, "test.txt")
137
+ fs.writeFileSync(filePath, "original")
138
+ const result = writeIfNotExists(filePath, "new content", "test.txt")
139
+ expect(result).toBe(false)
140
+ expect(fs.readFileSync(filePath, "utf8")).toBe("original")
141
+ })
142
+ })
143
+
144
+ // ─── getMissingDeps ─────────────────────────────────────────────────────────
145
+
146
+ describe("getMissingDeps", () => {
147
+ let tmp
148
+
149
+ beforeEach(() => {
150
+ tmp = makeTmpDir()
151
+ })
152
+
153
+ afterEach(() => {
154
+ cleanTmpDir(tmp)
155
+ })
156
+
157
+ it("returns all deps when no package.json exists", () => {
158
+ const result = getMissingDeps(tmp, ["react", "react-native"])
159
+ expect(result).toEqual(["react", "react-native"])
160
+ })
161
+
162
+ it("returns only missing deps", () => {
163
+ const pkgJson = {
164
+ dependencies: { react: "^18.0.0", clsx: "^2.0.0" },
165
+ devDependencies: { typescript: "^5.0.0" },
166
+ }
167
+ fs.writeFileSync(path.join(tmp, "package.json"), JSON.stringify(pkgJson))
168
+
169
+ const result = getMissingDeps(tmp, ["react", "nativewind", "typescript", "tailwindcss"])
170
+ expect(result).toEqual(["nativewind", "tailwindcss"])
171
+ })
172
+
173
+ it("returns empty array when all deps are present", () => {
174
+ const pkgJson = {
175
+ dependencies: { react: "^18.0.0", nativewind: "^4.0.0" },
176
+ }
177
+ fs.writeFileSync(path.join(tmp, "package.json"), JSON.stringify(pkgJson))
178
+
179
+ const result = getMissingDeps(tmp, ["react", "nativewind"])
180
+ expect(result).toEqual([])
181
+ })
182
+
183
+ it("checks devDependencies too", () => {
184
+ const pkgJson = {
185
+ devDependencies: { vitest: "^1.0.0" },
186
+ }
187
+ fs.writeFileSync(path.join(tmp, "package.json"), JSON.stringify(pkgJson))
188
+
189
+ const result = getMissingDeps(tmp, ["vitest"])
190
+ expect(result).toEqual([])
191
+ })
192
+ })
193
+
194
+ // ─── loadConfig / writeConfig ───────────────────────────────────────────────
195
+
196
+ describe("loadConfig / writeConfig", () => {
197
+ let tmp
198
+
199
+ beforeEach(() => {
200
+ tmp = makeTmpDir()
201
+ })
202
+
203
+ afterEach(() => {
204
+ cleanTmpDir(tmp)
205
+ })
206
+
207
+ it("returns null when no config file exists", () => {
208
+ expect(loadConfig(tmp)).toBeNull()
209
+ })
210
+
211
+ it("writes and reads config correctly", () => {
212
+ const config = {
213
+ globalCss: "app/global.css",
214
+ componentsUi: "app/components/ui",
215
+ lib: "app/lib",
216
+ }
217
+ writeConfig(tmp, config)
218
+
219
+ const configPath = path.join(tmp, CONFIG_FILENAME)
220
+ expect(fs.existsSync(configPath)).toBe(true)
221
+
222
+ const loaded = loadConfig(tmp)
223
+ expect(loaded).toEqual({ ...DEFAULT_CONFIG, ...config })
224
+ })
225
+
226
+ it("merges with DEFAULT_CONFIG on partial config", () => {
227
+ const partial = { globalCss: "custom/global.css" }
228
+ fs.writeFileSync(
229
+ path.join(tmp, CONFIG_FILENAME),
230
+ JSON.stringify(partial),
231
+ "utf8"
232
+ )
233
+
234
+ const loaded = loadConfig(tmp)
235
+ expect(loaded.globalCss).toBe("custom/global.css")
236
+ expect(loaded.componentsUi).toBe(DEFAULT_CONFIG.componentsUi)
237
+ expect(loaded.lib).toBe(DEFAULT_CONFIG.lib)
238
+ })
239
+
240
+ it("returns null for invalid JSON", () => {
241
+ fs.writeFileSync(path.join(tmp, CONFIG_FILENAME), "not json!", "utf8")
242
+ expect(loadConfig(tmp)).toBeNull()
243
+ })
244
+ })
245
+
246
+ // ─── getTailwindConfigContent ───────────────────────────────────────────────
247
+
248
+ describe("getTailwindConfigContent", () => {
249
+ it("generates valid tailwind config with default paths", () => {
250
+ const content = getTailwindConfigContent(DEFAULT_CONFIG)
251
+ expect(content).toContain("module.exports")
252
+ expect(content).toContain('require("nativewind/preset")')
253
+ expect(content).toContain("hsl(var(--primary))")
254
+ expect(content).toContain("hsl(var(--background))")
255
+ expect(content).toContain("var(--radius)")
256
+ expect(content).toContain("./src/**/*.{js,jsx,ts,tsx}")
257
+ })
258
+
259
+ it("includes custom componentsUi path in content array", () => {
260
+ const config = { ...DEFAULT_CONFIG, componentsUi: "app/ui" }
261
+ const content = getTailwindConfigContent(config)
262
+ expect(content).toContain("./app/ui/**/*.{js,jsx,ts,tsx}")
263
+ })
264
+ })
265
+
266
+ // ─── assertValidComponentConfig ─────────────────────────────────────────────
267
+
268
+ describe("assertValidComponentConfig", () => {
269
+ it("accepts a valid config with files", () => {
270
+ expect(() =>
271
+ assertValidComponentConfig("button", {
272
+ files: ["src/components/ui/button.tsx"],
273
+ })
274
+ ).not.toThrow()
275
+ })
276
+
277
+ it("accepts a valid config with files and dependencies", () => {
278
+ expect(() =>
279
+ assertValidComponentConfig("button", {
280
+ files: ["src/components/ui/button.tsx"],
281
+ dependencies: ["class-variance-authority"],
282
+ })
283
+ ).not.toThrow()
284
+ })
285
+
286
+ it("throws for null config", () => {
287
+ expect(() => assertValidComponentConfig("button", null)).toThrow(
288
+ /invalid/i
289
+ )
290
+ })
291
+
292
+ it("throws for missing files array", () => {
293
+ expect(() =>
294
+ assertValidComponentConfig("button", { dependencies: [] })
295
+ ).toThrow(/files/i)
296
+ })
297
+
298
+ it("throws for empty string in files array", () => {
299
+ expect(() =>
300
+ assertValidComponentConfig("button", { files: ["valid.tsx", ""] })
301
+ ).toThrow(/files/i)
302
+ })
303
+
304
+ it("throws for non-string in files array", () => {
305
+ expect(() =>
306
+ assertValidComponentConfig("button", { files: [123] })
307
+ ).toThrow(/files/i)
308
+ })
309
+
310
+ it("throws for invalid dependencies", () => {
311
+ expect(() =>
312
+ assertValidComponentConfig("button", {
313
+ files: ["button.tsx"],
314
+ dependencies: [123],
315
+ })
316
+ ).toThrow(/dependencies/i)
317
+ })
318
+
319
+ it("throws for empty string in dependencies", () => {
320
+ expect(() =>
321
+ assertValidComponentConfig("button", {
322
+ files: ["button.tsx"],
323
+ dependencies: ["valid", ""],
324
+ })
325
+ ).toThrow(/dependencies/i)
326
+ })
327
+ })
328
+
329
+ // ─── GLOBAL_CSS_CONTENT ─────────────────────────────────────────────────────
330
+
331
+ describe("GLOBAL_CSS_CONTENT", () => {
332
+ it("contains tailwind directives", () => {
333
+ expect(GLOBAL_CSS_CONTENT).toContain("@tailwind base")
334
+ expect(GLOBAL_CSS_CONTENT).toContain("@tailwind components")
335
+ expect(GLOBAL_CSS_CONTENT).toContain("@tailwind utilities")
336
+ })
337
+
338
+ it("contains light theme variables", () => {
339
+ expect(GLOBAL_CSS_CONTENT).toContain(":root")
340
+ expect(GLOBAL_CSS_CONTENT).toContain("--background:")
341
+ expect(GLOBAL_CSS_CONTENT).toContain("--primary:")
342
+ expect(GLOBAL_CSS_CONTENT).toContain("--radius:")
343
+ })
344
+
345
+ it("contains dark theme variables", () => {
346
+ expect(GLOBAL_CSS_CONTENT).toContain(".dark")
347
+ })
348
+ })
349
+
350
+ // ─── UTILS_CONTENT ──────────────────────────────────────────────────────────
351
+
352
+ describe("UTILS_CONTENT", () => {
353
+ it("exports the cn function", () => {
354
+ expect(UTILS_CONTENT).toContain("export function cn")
355
+ expect(UTILS_CONTENT).toContain("twMerge")
356
+ expect(UTILS_CONTENT).toContain("clsx")
357
+ })
358
+ })
359
+
360
+ // ─── formatError ────────────────────────────────────────────────────────────
361
+
362
+ describe("formatError", () => {
363
+ it("extracts message from Error objects", () => {
364
+ expect(formatError(new Error("test error"))).toBe("test error")
365
+ })
366
+
367
+ it("converts non-Error values to string", () => {
368
+ expect(formatError("string error")).toBe("string error")
369
+ expect(formatError(42)).toBe("42")
370
+ expect(formatError(null)).toBe("null")
371
+ })
372
+ })
package/src/bin.js CHANGED
@@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process"
7
7
  import { fileURLToPath } from "node:url"
8
8
 
9
9
  const BASE_URL =
10
- "https://raw.githubusercontent.com/KaloyanBehov/native-ui/main"
10
+ "https://raw.githubusercontent.com/KaloyanBehov/novaui/main"
11
11
 
12
12
  const CONFIG_FILENAME = "components.json"
13
13
  const FETCH_TIMEOUT_MS = 15000
@@ -534,42 +534,70 @@ function showVersion() {
534
534
  console.log(getCliVersion())
535
535
  }
536
536
 
537
+ // ─── Exports (for testing) ──────────────────────────────────────────────────
538
+
539
+ export {
540
+ DEFAULT_CONFIG,
541
+ CONFIG_FILENAME,
542
+ GLOBAL_CSS_CONTENT,
543
+ UTILS_CONTENT,
544
+ getTailwindConfigContent,
545
+ detectPackageManager,
546
+ ensureDir,
547
+ writeIfNotExists,
548
+ getMissingDeps,
549
+ loadConfig,
550
+ writeConfig,
551
+ assertValidComponentConfig,
552
+ fetchWithTimeout,
553
+ formatError,
554
+ getCliVersion,
555
+ init,
556
+ add,
557
+ }
558
+
537
559
  // ─── Main ───────────────────────────────────────────────────────────────────
538
560
 
539
- const command = process.argv[2]
540
- const arg = process.argv[3]
561
+ const isDirectRun =
562
+ process.argv[1] &&
563
+ (process.argv[1] === fileURLToPath(import.meta.url) ||
564
+ process.argv[1].endsWith("/bin.js"))
541
565
 
542
- async function main() {
543
- try {
544
- switch (command) {
545
- case "init":
546
- await init()
547
- break
548
- case "add":
549
- await add(arg)
550
- break
551
- case "--help":
552
- case "-h":
553
- showHelp()
554
- break
555
- case "--version":
556
- case "-v":
557
- showVersion()
558
- break
559
- default:
560
- if (command) {
561
- // Backwards compatible: treat unknown command as component name
562
- await add(command)
563
- } else {
566
+ if (isDirectRun) {
567
+ const command = process.argv[2]
568
+ const arg = process.argv[3]
569
+
570
+ async function main() {
571
+ try {
572
+ switch (command) {
573
+ case "init":
574
+ await init()
575
+ break
576
+ case "add":
577
+ await add(arg)
578
+ break
579
+ case "--help":
580
+ case "-h":
564
581
  showHelp()
565
- }
566
- break
582
+ break
583
+ case "--version":
584
+ case "-v":
585
+ showVersion()
586
+ break
587
+ default:
588
+ if (command) {
589
+ await add(command)
590
+ } else {
591
+ showHelp()
592
+ }
593
+ break
594
+ }
595
+ } catch (error) {
596
+ console.error("")
597
+ console.error(` ✗ Error: ${formatError(error)}`)
598
+ process.exit(1)
567
599
  }
568
- } catch (error) {
569
- console.error("")
570
- console.error(` ✗ Error: ${formatError(error)}`)
571
- process.exit(1)
572
600
  }
573
- }
574
601
 
575
- main()
602
+ main()
603
+ }