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 +146 -35
- package/package.json +13 -2
- package/src/__tests__/commands.test.js +342 -0
- package/src/__tests__/helpers.test.js +372 -0
- package/src/bin.js +61 -33
package/README.md
CHANGED
|
@@ -1,35 +1,108 @@
|
|
|
1
1
|
# novaui-cli
|
|
2
2
|
|
|
3
|
-
The CLI for **NovaUI**
|
|
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
|
|
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
|
-
##
|
|
7
|
+
## Prerequisites
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
60
|
+
The CLI will prompt you to configure file paths:
|
|
16
61
|
|
|
17
62
|
```
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
68
|
+
Press Enter to accept defaults or type custom paths.
|
|
69
|
+
|
|
70
|
+
#### What Gets Created
|
|
24
71
|
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
172
|
+
**Component naming**: Use the filename without extension — `button`, `card`, `alert-dialog`, `dropdown-menu`, etc.
|
|
88
173
|
|
|
89
174
|
## Configuration
|
|
90
175
|
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
220
|
+
## Resources
|
|
113
221
|
|
|
114
|
-
- [
|
|
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.
|
|
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/
|
|
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/
|
|
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
|
|
540
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
+
}
|