novaui-cli 1.0.9 → 1.1.2

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.
@@ -0,0 +1,51 @@
1
+ export const SUNSET_THEME_CSS = `@tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 24 100% 98%;
8
+ --foreground: 12 30% 18%;
9
+ --card: 0 0% 100%;
10
+ --card-foreground: 12 30% 18%;
11
+ --popover: 0 0% 100%;
12
+ --popover-foreground: 12 30% 18%;
13
+ --primary: 18 95% 53%;
14
+ --primary-foreground: 0 0% 100%;
15
+ --secondary: 22 78% 92%;
16
+ --secondary-foreground: 16 32% 26%;
17
+ --muted: 22 78% 92%;
18
+ --muted-foreground: 18 20% 46%;
19
+ --accent: 340 84% 93%;
20
+ --accent-foreground: 336 32% 28%;
21
+ --destructive: 0 73% 54%;
22
+ --destructive-foreground: 0 0% 100%;
23
+ --border: 20 58% 84%;
24
+ --input: 20 58% 84%;
25
+ --ring: 18 95% 53%;
26
+ --radius: 0.75rem;
27
+ }
28
+
29
+ .dark {
30
+ --background: 16 46% 12%;
31
+ --foreground: 24 60% 95%;
32
+ --card: 15 42% 16%;
33
+ --card-foreground: 24 60% 95%;
34
+ --popover: 15 42% 16%;
35
+ --popover-foreground: 24 60% 95%;
36
+ --primary: 24 100% 64%;
37
+ --primary-foreground: 16 46% 12%;
38
+ --secondary: 11 28% 24%;
39
+ --secondary-foreground: 24 60% 95%;
40
+ --muted: 11 28% 24%;
41
+ --muted-foreground: 20 24% 72%;
42
+ --accent: 332 42% 30%;
43
+ --accent-foreground: 24 60% 95%;
44
+ --destructive: 0 62% 45%;
45
+ --destructive-foreground: 0 0% 100%;
46
+ --border: 14 24% 28%;
47
+ --input: 14 24% 28%;
48
+ --ring: 24 100% 64%;
49
+ }
50
+ }
51
+ `
@@ -0,0 +1,22 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { CONFIG_FILENAME, DEFAULT_CONFIG } from '../constants.js'
4
+
5
+ /** Load components.json from cwd; returns null if missing or invalid. */
6
+ export function loadConfig(cwd) {
7
+ const configPath = path.join(cwd, CONFIG_FILENAME)
8
+ if (!fs.existsSync(configPath)) return null
9
+ try {
10
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'))
11
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
12
+ return { ...DEFAULT_CONFIG, ...raw }
13
+ } catch {
14
+ return null
15
+ }
16
+ }
17
+
18
+ /** Write components.json to cwd. */
19
+ export function writeConfig(cwd, config) {
20
+ const configPath = path.join(cwd, CONFIG_FILENAME)
21
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8')
22
+ }
@@ -0,0 +1,36 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ export function detectPackageManager() {
6
+ const userAgent = process.env.npm_config_user_agent || ''
7
+ if (userAgent.startsWith('yarn')) return { command: 'yarn', baseArgs: ['add'] }
8
+ if (userAgent.startsWith('pnpm')) return { command: 'pnpm', baseArgs: ['add'] }
9
+ if (userAgent.startsWith('bun')) return { command: 'bun', baseArgs: ['add'] }
10
+ return { command: 'npm', baseArgs: ['install'] }
11
+ }
12
+
13
+ export function installPackages(packages) {
14
+ if (!Array.isArray(packages) || packages.length === 0) return
15
+ const { command, baseArgs } = detectPackageManager()
16
+ execFileSync(command, [...baseArgs, ...packages], { stdio: 'inherit' })
17
+ }
18
+
19
+ export function getInstallHint(packages) {
20
+ const { command, baseArgs } = detectPackageManager()
21
+ return `${command} ${[...baseArgs, ...packages].join(' ')}`
22
+ }
23
+
24
+ /** Returns which of the requested deps are not listed in package.json (dependencies or devDependencies). */
25
+ export function getMissingDeps(cwd, deps) {
26
+ const pkgPath = path.join(cwd, 'package.json')
27
+ if (!fs.existsSync(pkgPath)) {
28
+ return [...deps]
29
+ }
30
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
31
+ const installed = new Set([
32
+ ...Object.keys(pkg.dependencies || {}),
33
+ ...Object.keys(pkg.devDependencies || {}),
34
+ ])
35
+ return deps.filter(d => !installed.has(d))
36
+ }
@@ -0,0 +1,21 @@
1
+ import { FETCH_TIMEOUT_MS } from '../constants.js'
2
+
3
+ export async function fetchWithTimeout(url) {
4
+ const controller = new AbortController()
5
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
6
+ try {
7
+ return await fetch(url, { signal: controller.signal })
8
+ } catch (error) {
9
+ if (error && error.name === 'AbortError') {
10
+ throw new Error(`Request timed out after ${FETCH_TIMEOUT_MS}ms: ${url}`)
11
+ }
12
+ throw error
13
+ } finally {
14
+ clearTimeout(timeout)
15
+ }
16
+ }
17
+
18
+ export function formatError(error) {
19
+ if (error instanceof Error && error.message) return error.message
20
+ return String(error)
21
+ }
@@ -0,0 +1,18 @@
1
+ import fs from 'node:fs'
2
+ import pc from 'picocolors'
3
+
4
+ export function ensureDir(dir) {
5
+ if (!fs.existsSync(dir)) {
6
+ fs.mkdirSync(dir, { recursive: true })
7
+ }
8
+ }
9
+
10
+ export function writeIfNotExists(filePath, content, label) {
11
+ if (fs.existsSync(filePath)) {
12
+ console.log(pc.dim(` ℹ ${label} already exists, skipping.`))
13
+ return false
14
+ }
15
+ fs.writeFileSync(filePath, content, 'utf8')
16
+ console.log(pc.green(` ✓ Created ${label}`))
17
+ return true
18
+ }
@@ -0,0 +1,60 @@
1
+ export function getTailwindConfigContent(config) {
2
+ const normalized = config.componentsUi.replace(/^\.\//, '')
3
+ const contentPaths = ['"./App.{js,jsx,ts,tsx}"', '"./src/**/*.{js,jsx,ts,tsx}"']
4
+ if (!normalized.startsWith('src/')) {
5
+ contentPaths.push(`"./${normalized}/**/*.{js,jsx,ts,tsx}"`)
6
+ }
7
+ return `/** @type {import('tailwindcss').Config} */
8
+ module.exports = {
9
+ content: [
10
+ ${contentPaths.join(',\n ')},
11
+ ],
12
+ presets: [require("nativewind/preset")],
13
+ theme: {
14
+ extend: {
15
+ colors: {
16
+ border: "hsl(var(--border))",
17
+ input: "hsl(var(--input))",
18
+ ring: "hsl(var(--ring))",
19
+ background: "hsl(var(--background))",
20
+ foreground: "hsl(var(--foreground))",
21
+ primary: {
22
+ DEFAULT: "hsl(var(--primary))",
23
+ foreground: "hsl(var(--primary-foreground))",
24
+ },
25
+ secondary: {
26
+ DEFAULT: "hsl(var(--secondary))",
27
+ foreground: "hsl(var(--secondary-foreground))",
28
+ },
29
+ destructive: {
30
+ DEFAULT: "hsl(var(--destructive))",
31
+ foreground: "hsl(var(--destructive-foreground))",
32
+ },
33
+ muted: {
34
+ DEFAULT: "hsl(var(--muted))",
35
+ foreground: "hsl(var(--muted-foreground))",
36
+ },
37
+ accent: {
38
+ DEFAULT: "hsl(var(--accent))",
39
+ foreground: "hsl(var(--accent-foreground))",
40
+ },
41
+ popover: {
42
+ DEFAULT: "hsl(var(--popover))",
43
+ foreground: "hsl(var(--popover-foreground))",
44
+ },
45
+ card: {
46
+ DEFAULT: "hsl(var(--card))",
47
+ foreground: "hsl(var(--card-foreground))",
48
+ },
49
+ },
50
+ borderRadius: {
51
+ lg: "var(--radius)",
52
+ md: "calc(var(--radius) - 2px)",
53
+ sm: "calc(var(--radius) - 4px)",
54
+ },
55
+ },
56
+ },
57
+ plugins: [],
58
+ };
59
+ `
60
+ }
@@ -0,0 +1,17 @@
1
+ export function assertValidComponentConfig(componentName, componentConfig) {
2
+ if (!componentConfig || typeof componentConfig !== 'object') {
3
+ throw new Error(`Registry entry for "${componentName}" is invalid.`)
4
+ }
5
+
6
+ const { files, dependencies } = componentConfig
7
+ if (!Array.isArray(files) || files.some(file => typeof file !== 'string' || file.trim() === '')) {
8
+ throw new Error(`Registry entry for "${componentName}" must include a valid "files" array.`)
9
+ }
10
+
11
+ if (
12
+ dependencies !== undefined &&
13
+ (!Array.isArray(dependencies) || dependencies.some(dep => typeof dep !== 'string' || dep.trim() === ''))
14
+ ) {
15
+ throw new Error(`Registry entry for "${componentName}" has an invalid "dependencies" array.`)
16
+ }
17
+ }
@@ -0,0 +1,17 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __filename = fileURLToPath(import.meta.url)
6
+ const __dirname = path.dirname(__filename)
7
+
8
+ const CLI_PACKAGE_JSON_PATH = path.resolve(__dirname, '../../package.json')
9
+
10
+ export function getCliVersion() {
11
+ try {
12
+ const pkg = JSON.parse(fs.readFileSync(CLI_PACKAGE_JSON_PATH, 'utf8'))
13
+ return pkg.version || 'unknown'
14
+ } catch {
15
+ return 'unknown'
16
+ }
17
+ }
@@ -1,342 +0,0 @@
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
- })