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 +111 -0
- package/package.json +40 -13
- package/src/__tests__/commands.test.js +491 -0
- package/src/__tests__/fuzzy.test.js +150 -0
- package/src/__tests__/helpers.test.js +520 -0
- package/src/__tests__/preflight.test.js +379 -0
- package/src/__tests__/version-check.test.js +58 -0
- package/src/bin.js +103 -590
- package/src/commands/add.js +168 -0
- package/src/commands/init.js +330 -0
- package/src/constants.js +101 -0
- package/src/themes/default.js +51 -0
- package/src/themes/index.js +48 -0
- package/src/themes/ocean.js +51 -0
- package/src/themes/sunset.js +51 -0
- package/src/utils/config.js +22 -0
- package/src/utils/deps.js +40 -0
- package/src/utils/fetch.js +21 -0
- package/src/utils/fs-helpers.js +18 -0
- package/src/utils/fuzzy.js +99 -0
- package/src/utils/preflight.js +172 -0
- package/src/utils/tailwind.js +60 -0
- package/src/utils/validate.js +17 -0
- package/src/utils/version-check.js +136 -0
- package/src/utils/version.js +17 -0
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.
|
|
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
|
-
"
|
|
18
|
+
"react native",
|
|
18
19
|
"nativewind",
|
|
19
|
-
"
|
|
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
|
+
})
|