Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.15.3",
"@types/prompts": "^2.4.9",
"tsup": "^8.5.0"
},
"dependencies": {
"commander": "^14.0.3",
"execa": "^9.6.1",
"kleur": "^4.1.5",
"prompts": "^2.4.2",
"zod": "^4.4.3"
}
}
79 changes: 79 additions & 0 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import kleur from "kleur";
import prompts from "prompts";
import { fetchIndex } from "../registry/fetch.js";
import { resolveItems } from "../registry/resolve.js";
import { loadConfig } from "../utils/config.js";
import { installDependencies } from "../utils/install-deps.js";
import { writeItemFiles } from "../utils/write-files.js";

type AddOptions = {
cwd: string;
overwrite: boolean;
yes: boolean;
skipInstall: boolean;
registry?: string;
};

export async function runAdd(names: string[], options: AddOptions) {
const config = await loadConfig(options.cwd);
if (!config) {
console.error(
kleur.red("components.json not found. Run `modui init` first."),
);
process.exit(1);
Comment on lines +20 to +23
}

const registry = options.registry ?? config.registry;

let selected = names;
if (selected.length === 0) {
const index = await fetchIndex(registry);
const { picked } = await prompts({
type: "multiselect",
name: "picked",
message: "Which components would you like to add?",
choices: index.components.map((c) => ({ title: c.name, value: c.name })),
hint: "Space to select. Enter to submit.",
instructions: false,
});
if (!picked || picked.length === 0) {
console.log(kleur.yellow("No components selected."));
return;
}
selected = picked;
}
Comment on lines +28 to +44

console.log(kleur.cyan(`\nResolving ${selected.join(", ")}...`));
const items = await resolveItems(registry, selected);

const npmDeps = new Set<string>();
for (const item of items) {
for (const dep of item.dependencies) npmDeps.add(dep);
}

const resolvedNames = items.map((i) => i.name);
const extra = resolvedNames.filter((n) => !selected.includes(n));
if (extra.length > 0) {
console.log(
kleur.gray(` including dependencies: ${extra.join(", ")}`),
);
}

console.log(kleur.cyan("\nWriting files..."));
await writeItemFiles(options.cwd, config, items, {
overwrite: options.overwrite,
yes: options.yes,
});

if (!options.skipInstall && npmDeps.size > 0) {
await installDependencies(options.cwd, [...npmDeps]);
} else if (npmDeps.size > 0) {
console.log(
kleur.yellow(
`\nSkipped install. Run manually: ${[...npmDeps].join(" ")}`,
),
);
}

console.log(kleur.green("\n✓ Done."));
}
78 changes: 78 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { existsSync } from "node:fs";
import kleur from "kleur";
import prompts from "prompts";
import { fetchTheme } from "../registry/fetch.js";
import {
DEFAULT_CONFIG,
configPath,
loadConfig,
writeConfig,
type Config,
} from "../utils/config.js";
import { writeItemFiles } from "../utils/write-files.js";

type InitOptions = {
cwd: string;
yes: boolean;
theme: string;
registry?: string;
};

export async function runInit(options: InitOptions) {
const existing = existsSync(configPath(options.cwd))
? await loadConfig(options.cwd)
: null;

if (existing && !options.yes) {
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "components.json already exists. Overwrite?",
initial: false,
});
if (!proceed) {
console.log(kleur.yellow("Aborted."));
return;
}
}

let config: Config = existing ?? DEFAULT_CONFIG;
if (!options.yes) {
const answers = await prompts([
{
type: "text",
name: "ui",
message: "Where should UI components be placed?",
initial: config.paths.ui,
},
{
type: "text",
name: "themes",
message: "Where should theme files be placed?",
initial: config.paths.themes,
},
]);
if (answers.ui && answers.themes) {
config = {
...config,
paths: { ui: answers.ui, themes: answers.themes },
};
}
}

if (options.registry) {
config = { ...config, registry: options.registry };
}

await writeConfig(options.cwd, config);
console.log(kleur.green(`✓ Created components.json`));

console.log(kleur.cyan(`\nFetching theme "${options.theme}"...`));
const theme = await fetchTheme(config.registry, options.theme);
await writeItemFiles(options.cwd, config, [theme], {
overwrite: options.yes,
yes: options.yes,
});

console.log(kleur.green("\n✓ Done. Try `modui add button`."));
}
66 changes: 54 additions & 12 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,58 @@
import { Command } from "commander";
import kleur from "kleur";
import pkg from "../package.json" with { type: "json" };
import { runAdd } from "./commands/add.js";
import { runInit } from "./commands/init.js";

const { version } = pkg;
const program = new Command();

const args = process.argv.slice(2);
program
.name("modui")
.description("CSS Modules UI components for React. Copy, paste, customize.")
.version(pkg.version);

if (args.includes("--version") || args.includes("-v")) {
console.log(version);
} else {
console.log(`modui v${version}`);
console.log("CSS Modules UI components for React.\n");
console.log("Usage:");
console.log(" modui init Initialize modui in your project");
console.log(" modui add <name> Add a component");
console.log(" modui --version Show version");
}
program
.command("init")
.description("Initialize modui in your project")
.option("-y, --yes", "Skip prompts and use defaults", false)
.option("--theme <name>", "Theme to install", "default")
.option("--registry <url>", "Override registry URL")
.option("--cwd <path>", "Working directory", process.cwd())
.action(async (opts) => {
try {
await runInit({
cwd: opts.cwd,
yes: opts.yes,
theme: opts.theme,
registry: opts.registry,
});
} catch (err) {
console.error(kleur.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
});

program
.command("add [components...]")
.description("Add components to your project")
.option("-y, --yes", "Skip prompts", false)
.option("--overwrite", "Overwrite existing files without prompting", false)
.option("--skip-install", "Don't install npm dependencies", false)
.option("--registry <url>", "Override registry URL")
.option("--cwd <path>", "Working directory", process.cwd())
.action(async (components: string[], opts) => {
try {
await runAdd(components, {
cwd: opts.cwd,
yes: opts.yes,
overwrite: opts.overwrite,
skipInstall: opts.skipInstall,
registry: opts.registry,
});
} catch (err) {
console.error(kleur.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
});

program.parseAsync();
59 changes: 59 additions & 0 deletions packages/cli/src/registry/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
registryIndexSchema,
registryItemSchema,
type RegistryIndex,
type RegistryItem,
} from "./schema.js";

const cache = new Map<string, unknown>();

async function fetchJson(url: string): Promise<unknown> {
if (cache.has(url)) return cache.get(url);

let raw: string;
if (url.startsWith("file://")) {
raw = await readFile(fileURLToPath(url), "utf8");
} else if (url.startsWith("/") || url.match(/^[A-Za-z]:[\\/]/)) {
raw = await readFile(url, "utf8");
} else {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
raw = await res.text();
}

const parsed = JSON.parse(raw);
cache.set(url, parsed);
return parsed;
}

function joinUrl(base: string, path: string): string {
if (base.startsWith("http")) {
return `${base.replace(/\/$/, "")}/${path}`;
}
return `${base.replace(/\/$/, "")}/${path}`;
}

export async function fetchIndex(registry: string): Promise<RegistryIndex> {
const data = await fetchJson(joinUrl(registry, "index.json"));
return registryIndexSchema.parse(data);
}

export async function fetchItem(
registry: string,
name: string,
): Promise<RegistryItem> {
const data = await fetchJson(joinUrl(registry, `${name}.json`));
return registryItemSchema.parse(data);
}

export async function fetchTheme(
registry: string,
name: string,
): Promise<RegistryItem> {
const data = await fetchJson(joinUrl(registry, `themes/${name}.json`));
return registryItemSchema.parse(data);
}
26 changes: 26 additions & 0 deletions packages/cli/src/registry/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { fetchItem } from "./fetch.js";
import type { RegistryItem } from "./schema.js";

export async function resolveItems(
registry: string,
names: string[],
): Promise<RegistryItem[]> {
const visited = new Set<string>();
const collected: RegistryItem[] = [];

async function visit(name: string) {
if (visited.has(name)) return;
visited.add(name);
const item = await fetchItem(registry, name);
for (const dep of item.registryDependencies) {
await visit(dep);
}
collected.push(item);
}

for (const name of names) {
await visit(name);
}

return collected;
}
33 changes: 33 additions & 0 deletions packages/cli/src/registry/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { z } from "zod";

export const registryFileSchema = z.object({
path: z.string(),
content: z.string(),
type: z.enum(["component", "style", "theme"]),
});

export const registryItemSchema = z.object({
name: z.string(),
description: z.string().optional(),
dependencies: z.array(z.string()).default([]),
registryDependencies: z.array(z.string()).default([]),
files: z.array(registryFileSchema),
});

export const registryIndexEntrySchema = z.object({
name: z.string(),
description: z.string().optional(),
dependencies: z.array(z.string()).default([]),
registryDependencies: z.array(z.string()).default([]),
});

export const registryIndexSchema = z.object({
version: z.string(),
components: z.array(registryIndexEntrySchema),
themes: z.array(z.string()),
});

export type RegistryFile = z.infer<typeof registryFileSchema>;
export type RegistryItem = z.infer<typeof registryItemSchema>;
export type RegistryIndex = z.infer<typeof registryIndexSchema>;
export type RegistryIndexEntry = z.infer<typeof registryIndexEntrySchema>;
Loading