merge: feat/create-antd-admin-cli (create-antd-admin CLI)

Made-with: Cursor
This commit is contained in:
zuiidea
2026-04-15 10:45:29 +08:00
29 changed files with 1654 additions and 26 deletions
+2
View File
@@ -49,6 +49,8 @@ Balanced admin monorepo: MSW mocks, minimal RBAC, full CRUD, and Playwright E2E
## Templates
`pnpm dlx create-antd-admin@latest`
| Directory | Use when |
| -------------------- | -------- |
| [`apps/with-lingui`](./apps/with-lingui/) | You want **LinguiJS** (en + zh), `.po` extract/compile, and a language switcher — this is the fuller reference stack. |
@@ -171,7 +171,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "../..");
/** From `packages/create/scripts/`, three levels up = monorepo root. */
const repoRoot = path.resolve(__dirname, "../../..");
const appsDir = path.join(repoRoot, "apps");
const outFile = path.resolve(__dirname, "../dist/examples.json");
@@ -0,0 +1,5 @@
{
"name": "template-placeholder",
"version": "0.0.0",
"private": true
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "create-antd-admin",
"version": "6.0.0",
"description": "Create an Antd Admin app from official examples",
"license": "MIT",
"type": "module",
"bin": {
"create-antd-admin": "./dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"prepublishOnly": "pnpm run build",
"build": "tsup && node ./scripts/write-examples-json.mjs",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@inquirer/prompts": "^7.10.1",
"commander": "^14.0.0",
"giget": "^3.2.0",
"picocolors": "^1.0.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsup": "^8.3.5",
"typescript": "~5.9.3",
"vitest": "^3.0.5"
}
}
@@ -0,0 +1,20 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "../../..");
const appsDir = path.join(repoRoot, "apps");
const outFile = path.resolve(__dirname, "../dist/examples.json");
const exclude = new Set(["docs"]);
const names = fs
.readdirSync(appsDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.filter((name) => !exclude.has(name))
.sort();
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, JSON.stringify({ examples: names }, null, 2));
console.log(`write-examples-json: wrote ${outFile}`, names);
+77
View File
@@ -0,0 +1,77 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Command, Option } from "commander";
import pc from "picocolors";
import { runCreate } from "./commands/create.js";
import { EXIT_ERROR, EXIT_SIGINT } from "./constants.js";
import { CliError } from "./errors.js";
const pkg = JSON.parse(
readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "../package.json"),
"utf8"
)
) as { version: string };
const program = new Command();
program
.name("create-antd-admin")
.description("Create an Antd Admin app from official examples")
.version(pkg.version, "-v, --version")
.argument("[project-directory]", "Directory for the new project")
.option("-e, --example <name|url>", "Example name or template URL")
.option("--example-path <path>", "Path inside repo when URL is ambiguous")
.addOption(
new Option("-m, --package-manager <pm>", "Package manager").choices([
"npm",
"yarn",
"pnpm",
"bun",
])
)
.option("--skip-install", "Skip installing dependencies", false)
.option("--skip-transforms", "Skip code transforms", false)
.option("--no-git", "Skip git repository initialization")
.action(
async (
projectDirectory: string | undefined,
cmdOpts: {
example?: string;
examplePath?: string;
packageManager?: "npm" | "yarn" | "pnpm" | "bun";
skipInstall?: boolean;
skipTransforms?: boolean;
git?: boolean;
}
) => {
try {
await runCreate({
projectDirectory,
example: cmdOpts.example,
examplePath: cmdOpts.examplePath,
packageManager: cmdOpts.packageManager,
skipInstall: Boolean(cmdOpts.skipInstall),
skipTransforms: Boolean(cmdOpts.skipTransforms),
git: cmdOpts.git !== false,
});
} catch (e) {
if (e instanceof CliError) {
console.error(pc.red(e.message));
process.exit(e.exitCode);
}
console.error(e);
process.exit(EXIT_ERROR);
}
}
);
process.on("SIGINT", () => {
process.exit(EXIT_SIGINT);
});
program.parseAsync(process.argv).catch((e) => {
console.error(e);
process.exit(EXIT_ERROR);
});
+99
View File
@@ -0,0 +1,99 @@
import fsSync from "node:fs";
import path from "node:path";
import pc from "picocolors";
import { assertValidExample, loadExamplesManifest } from "../examples.js";
import { downloadExampleToTemp } from "../download.js";
import { installDependencies } from "../install.js";
import {
detectAvailableManagers,
type PackageManagerName,
} from "../package-manager.js";
import { resolveTargetRoot } from "../paths.js";
import {
promptExample,
promptPackageManager,
promptPackageName,
promptTargetDir,
} from "../prompts.js";
import { resolveExampleSource } from "../resolve-example-source.js";
import { tryGitInit } from "../git.js";
import { runTransforms } from "../transforms/run-transforms.js";
export async function runCreate(
opts: Omit<import("../types.js").CreateCliOptions, "git"> & {
projectDirectory?: string;
git?: boolean;
}
): Promise<void> {
const cwd = process.cwd();
const manifest = loadExamplesManifest();
const exampleTrim = opts.example?.trim();
const example = exampleTrim
? exampleTrim
: await promptExample(manifest);
if (!/^https?:\/\//i.test(example)) {
assertValidExample(example, manifest);
}
const projectDirRaw = opts.projectDirectory?.trim();
const projectDir = projectDirRaw
? projectDirRaw
: await promptTargetDir();
const targetRoot = resolveTargetRoot(cwd, projectDir);
fsSync.mkdirSync(targetRoot, { recursive: true });
const defaultPkgName =
projectDir === "."
? (() => {
const normalized = path
.basename(cwd)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || "antd-admin-app";
})()
: path.basename(targetRoot);
const packageName = await promptPackageName(defaultPkgName);
const packageManager: PackageManagerName =
opts.packageManager ??
(await promptPackageManager(detectAvailableManagers()));
if (opts.skipTransforms && opts.packageManager) {
console.warn(
pc.yellow(
"--skip-transforms was used with an explicit package manager; package.json name may not be updated before install."
)
);
}
const { gigetSource } = resolveExampleSource({
example,
examplePath: opts.examplePath,
});
const tmp = await downloadExampleToTemp(gigetSource);
try {
fsSync.cpSync(tmp, targetRoot, { recursive: true, dereference: true });
} finally {
fsSync.rmSync(tmp, { recursive: true, force: true });
}
await runTransforms({
projectRoot: targetRoot,
packageName,
skip: opts.skipTransforms,
});
if (!opts.skipInstall) {
installDependencies(targetRoot, packageManager);
}
tryGitInit(targetRoot, opts.git !== false);
console.log(pc.green(`Success! Project created at ${targetRoot}`));
}
+11
View File
@@ -0,0 +1,11 @@
export const DEFAULT_GITHUB_REPO = "zuiidea/antd-admin";
/** Override full repo slug `owner/name` */
export const ENV_REPO = "CREATE_ANTD_ADMIN_REPO";
/** Override ref: branch name, tag, or commit sha */
export const ENV_REF = "CREATE_ANTD_ADMIN_REF";
export const EXIT_SUCCESS = 0;
export const EXIT_ERROR = 1;
export const EXIT_SIGINT = 130;
+24
View File
@@ -0,0 +1,24 @@
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { downloadTemplate } from "giget";
import { CliError } from "./errors.js";
export async function downloadExampleToTemp(
gigetSource: string
): Promise<string> {
const dir = path.join(os.tmpdir(), `create-antd-admin-${randomUUID()}`);
try {
const { dir: outDir } = await downloadTemplate(gigetSource, {
dir,
forceClean: true,
});
return outDir;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new CliError(
`Failed to download template.\nSource: ${gigetSource}\n${msg}`,
"DOWNLOAD_FAILED"
);
}
}
+14
View File
@@ -0,0 +1,14 @@
export class CliError extends Error {
constructor(
message: string,
public readonly code:
| "INVALID_INPUT"
| "DOWNLOAD_FAILED"
| "TRANSFORM_FAILED"
| "INSTALL_FAILED",
public readonly exitCode = 1
) {
super(message);
this.name = "CliError";
}
}
+35
View File
@@ -0,0 +1,35 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { CliError } from "./errors.js";
export type ExamplesManifest = { examples: string[] };
export function loadExamplesManifest(): string[] {
const manifestPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"examples.json"
);
if (!fs.existsSync(manifestPath)) {
throw new CliError(
`Missing ${manifestPath}. Run pnpm run build in packages/create.`,
"INVALID_INPUT"
);
}
const data = JSON.parse(
fs.readFileSync(manifestPath, "utf8")
) as ExamplesManifest;
if (!Array.isArray(data.examples) || data.examples.length === 0) {
throw new CliError("Invalid examples.json", "INVALID_INPUT");
}
return data.examples;
}
export function assertValidExample(name: string, list: string[]): void {
if (!list.includes(name)) {
throw new CliError(
`Unknown example "${name}". Valid: ${list.join(", ")}`,
"INVALID_INPUT"
);
}
}
+22
View File
@@ -0,0 +1,22 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export function tryGitInit(root: string, enabled: boolean): boolean {
if (!enabled) {
const gitDir = path.join(root, ".git");
if (fs.existsSync(gitDir)) {
fs.rmSync(gitDir, { recursive: true, force: true });
}
return false;
}
const init = spawnSync("git", ["init"], { cwd: root, stdio: "ignore" });
if (init.status !== 0) return false;
spawnSync("git", ["add", "-A"], { cwd: root, stdio: "ignore" });
spawnSync(
"git",
["commit", "-m", "chore: initial commit from create-antd-admin"],
{ cwd: root, stdio: "ignore" }
);
return true;
}
+22
View File
@@ -0,0 +1,22 @@
import { spawnSync } from "node:child_process";
import type { PackageManagerName } from "./package-manager.js";
import { spawnInstall } from "./package-manager.js";
import { CliError } from "./errors.js";
export function installDependencies(
cwd: string,
manager: PackageManagerName
): void {
const { cmd, args } = spawnInstall(manager);
const res = spawnSync(cmd, args, {
cwd,
stdio: "inherit",
env: { ...process.env, CI: "1" },
});
if (res.error || res.status !== 0) {
throw new CliError(
`${cmd} ${args.join(" ")} failed in ${cwd}`,
"INSTALL_FAILED"
);
}
}
@@ -0,0 +1,26 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { runTransforms } from "../transforms/run-transforms.js";
describe("create pipeline transforms", () => {
it("renames package.json", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cad-"));
const fixture = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"../../fixtures/minimal-app"
);
fs.cpSync(fixture, tmp, { recursive: true });
await runTransforms({
projectRoot: tmp,
packageName: "my-fine-app",
skip: false,
});
const pkg = JSON.parse(
fs.readFileSync(path.join(tmp, "package.json"), "utf8")
);
expect(pkg.name).toBe("my-fine-app");
});
});
+35
View File
@@ -0,0 +1,35 @@
import { spawnSync } from "node:child_process";
export type PackageManagerName = "npm" | "pnpm" | "yarn" | "bun";
const ORDER: PackageManagerName[] = ["pnpm", "npm", "yarn", "bun"];
export function isManagerAvailable(name: PackageManagerName): boolean {
const result = spawnSync(name, ["--version"], { stdio: "ignore" });
return result.status === 0;
}
export function detectAvailableManagers(): PackageManagerName[] {
return ORDER.filter(isManagerAvailable);
}
/** Install argv; cwd is applied by install.ts via spawnSync `cwd` option. */
export function spawnInstall(name: PackageManagerName): {
cmd: string;
args: string[];
} {
switch (name) {
case "pnpm":
return { cmd: "pnpm", args: ["install"] };
case "npm":
return { cmd: "npm", args: ["install"] };
case "yarn":
return { cmd: "yarn", args: ["install"] };
case "bun":
return { cmd: "bun", args: ["install"] };
default: {
const _: never = name;
throw new Error(`unsupported manager ${_}`);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import { CliError } from "./errors.js";
export function resolveTargetRoot(cwd: string, dir: string): string {
const abs = path.resolve(cwd, dir);
if (fs.existsSync(abs)) {
const entries = fs.readdirSync(abs);
if (entries.length > 0) {
throw new CliError(
`Target directory is not empty: ${abs}`,
"INVALID_INPUT"
);
}
}
return abs;
}
+61
View File
@@ -0,0 +1,61 @@
import { confirm, input, select } from "@inquirer/prompts";
import type { PackageManagerName } from "./package-manager.js";
import { CliError } from "./errors.js";
import { normalizePackageName } from "./utils/normalize-package-name.js";
export async function promptExample(examples: string[]): Promise<string> {
return select({
message: "Which example do you want?",
choices: examples.map((value) => ({ name: value, value })),
});
}
export async function promptTargetDir(): Promise<string> {
const raw = await input({
message:
"Project directory (relative path, or '.' for current directory)?",
default: "my-antd-admin",
});
const dir = (raw.trim() || "my-antd-admin").replace(/\\/g, "/");
if (dir === ".") {
const ok = await confirm({
message: "Scaffold into the current directory?",
default: false,
});
if (!ok) {
throw new CliError("Aborted.", "INVALID_INPUT");
}
}
return dir;
}
export async function promptPackageName(defaultName: string): Promise<string> {
const raw = await input({
message: "npm package name for package.json?",
default: defaultName,
validate: (value) => {
try {
normalizePackageName(value);
return true;
} catch (e) {
return e instanceof Error ? e.message : String(e);
}
},
});
return normalizePackageName(raw);
}
export async function promptPackageManager(
available: PackageManagerName[]
): Promise<PackageManagerName> {
if (available.length === 0) {
throw new CliError(
"No supported package manager found in PATH (pnpm, npm, yarn, bun).",
"INVALID_INPUT"
);
}
return select({
message: "Which package manager do you want to use?",
choices: available.map((value) => ({ name: value, value })),
});
}
@@ -0,0 +1,20 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { resolveExampleSource } from "./resolve-example-source.js";
describe("resolveExampleSource", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("resolves short example with default repo and env ref", () => {
vi.stubEnv("CREATE_ANTD_ADMIN_REF", "main");
const r = resolveExampleSource({ example: "basic" });
expect(r.gigetSource).toBe("github:zuiidea/antd-admin/apps/basic#main");
});
it("passes through https URL", () => {
const url = "https://api.github.com/repos/zuiidea/antd-admin/tarball/main";
const r = resolveExampleSource({ example: url });
expect(r.gigetSource).toBe(url);
});
});
@@ -0,0 +1,39 @@
import { DEFAULT_GITHUB_REPO, ENV_REPO } from "./constants.js";
import { CliError } from "./errors.js";
import { resolveGitRef } from "./resolve-ref.js";
export type ResolvedExampleSource = { gigetSource: string; displayName: string };
function defaultRepo(): string {
return process.env[ENV_REPO]?.trim() || DEFAULT_GITHUB_REPO;
}
export function resolveExampleSource(input: {
example?: string;
examplePath?: string;
}): ResolvedExampleSource {
const example = input.example?.trim();
if (!example) {
throw new CliError("example is required", "INVALID_INPUT");
}
const { examplePath } = input;
const ref = resolveGitRef();
if (/^https?:\/\//i.test(example)) {
return { gigetSource: example, displayName: example };
}
const repo = defaultRepo();
if (!example.includes("/")) {
const sub = examplePath
? `apps/${example}/${examplePath}`.replace(/\/+/g, "/")
: `apps/${example}`;
const gigetSource = `github:${repo}/${sub}#${ref}`;
return { gigetSource, displayName: example };
}
const pathPart = examplePath ? `${example}/${examplePath}` : example;
const gigetSource = `github:${pathPart}#${ref}`;
return { gigetSource, displayName: pathPart };
}
+26
View File
@@ -0,0 +1,26 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ENV_REF } from "./constants.js";
/**
* Priority: CREATE_ANTD_ADMIN_REF env → read sibling package.json version as `v${version}` → `main` on failure.
* When bundled to dist/cli.js, ../package.json is packages/create/package.json.
*/
export function resolveGitRef(): string {
const env = process.env[ENV_REF]?.trim();
if (env) return env;
try {
const pkgPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"package.json"
);
const raw = fs.readFileSync(pkgPath, "utf8");
const { version } = JSON.parse(raw) as { version: string };
return `v${version}`;
} catch {
return "main";
}
}
@@ -0,0 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import { CliError } from "../errors.js";
export function transformPackageJsonName(
projectRoot: string,
name: string
): void {
const pkgPath = path.join(projectRoot, "package.json");
if (!fs.existsSync(pkgPath)) {
throw new CliError(`No package.json at ${pkgPath}`, "TRANSFORM_FAILED");
}
const raw = fs.readFileSync(pkgPath, "utf8");
const json = JSON.parse(raw) as { name?: string };
json.name = name;
fs.writeFileSync(pkgPath, JSON.stringify(json, null, 2) + "\n");
}
@@ -0,0 +1,10 @@
import { transformPackageJsonName } from "./package-json-name.js";
export async function runTransforms(opts: {
projectRoot: string;
packageName: string;
skip: boolean;
}): Promise<void> {
if (opts.skip) return;
transformPackageJsonName(opts.projectRoot, opts.packageName);
}
+17
View File
@@ -0,0 +1,17 @@
import type { PackageManagerName } from "./package-manager.js";
export type CreateCliOptions = {
packageManager?: PackageManagerName;
skipInstall: boolean;
skipTransforms: boolean;
example?: string;
examplePath?: string;
git: boolean;
};
export type CreateContext = {
example: string;
targetDir: string;
packageName: string;
packageManager: PackageManagerName;
};
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { normalizePackageName } from "./normalize-package-name.js";
describe("normalizePackageName", () => {
it("lowercases and replaces spaces", () => {
expect(normalizePackageName("My App")).toBe("my-app");
});
it("rejects leading dot", () => {
expect(() => normalizePackageName(".bad")).toThrow(/cannot start/);
});
});
@@ -0,0 +1,26 @@
import { CliError } from "../errors.js";
/**
* npm package name rules (simplified): lowercase, URL-safe, no leading dot, segments 1-214.
*/
export function normalizePackageName(input: string): string {
const trimmed = input.trim().toLowerCase();
if (!trimmed) {
throw new CliError("Project name cannot be empty", "INVALID_INPUT");
}
if (trimmed.startsWith(".") || trimmed.startsWith("_")) {
throw new CliError(
"Project name cannot start with . or _",
"INVALID_INPUT"
);
}
const safe = trimmed
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-._~]/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
if (!safe || safe.length > 214) {
throw new CliError("Project name is invalid or too long", "INVALID_INPUT");
}
return safe;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"],
"resolveJsonModule": true,
"rootDir": ".",
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts", "tsup.config.ts", "vitest.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/cli.ts"],
format: ["esm"],
platform: "node",
target: "node20",
outDir: "dist",
clean: true,
banner: {
js: "#!/usr/bin/env node",
},
shims: false,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+942 -25
View File
File diff suppressed because it is too large Load Diff