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.
- package/package.json +9 -1
- package/src/bin.js +95 -574
- package/src/commands/add.js +171 -0
- package/src/commands/init.js +235 -0
- package/src/components.json +6 -0
- package/src/constants.js +20 -0
- package/src/global.css +50 -0
- package/src/tailwind.config.js +54 -0
- package/src/themes/default.js +51 -0
- package/src/themes/index.js +17 -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 +36 -0
- package/src/utils/fetch.js +21 -0
- package/src/utils/fs-helpers.js +18 -0
- package/src/utils/tailwind.js +60 -0
- package/src/utils/validate.js +17 -0
- package/src/utils/version.js +17 -0
- package/src/__tests__/commands.test.js +0 -342
- package/src/__tests__/helpers.test.js +0 -372
|
@@ -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
|
-
})
|