Skip to content
Merged
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
42 changes: 19 additions & 23 deletions cli/create-vc-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,55 +5,51 @@ Scaffolding tool for VC Shell applications and modules.
## Create a New Project

```bash
npx create-vc-app [project-name]
npx @vc-shell/create-vc-app [project-name]
```

Interactive prompts guide you through project setup. Three project types are available:
Interactive prompts guide you through project setup. Two project types are available:

| Type | Description |
| ------------------ | -------------------------------------------------- |
| **Standalone App** | Full application with bundled modules |
| **Dynamic Module** | Remote module loaded by host via Module Federation |
| **Host App** | Shell that loads dynamic modules at runtime |

### Non-Interactive Mode

Pass flags to skip prompts:

```bash
# Standalone app with dashboard and sample data
npx create-vc-app my-app --type standalone --module-name "Products" --dashboard --mocks
npx @vc-shell/create-vc-app my-app --type standalone --module-name "Products" --dashboard --mocks

# Dynamic module
npx create-vc-app my-module --type dynamic-module --module-name "Reviews"

# Host app with tenant routing and AI agent
npx create-vc-app my-shell --type host-app --dashboard --tenant-routes --ai-agent
npx @vc-shell/create-vc-app my-module --type dynamic-module --module-name "Reviews"
```

### Options

| Option | Description | Default |
| ---------------------- | ------------------------------------------------------------ | ---------------------- |
| `--type <type>` | Project type: `standalone` \| `dynamic-module` \| `host-app` | _(prompted)_ |
| `--name`, `--app-name` | Application name | Directory name |
| `--package-name` | npm package name | App name (validated) |
| `--module-name` | Initial module name | App name in title case |
| `--base-path` | Base path for the application | `/apps/<name>/` |
| `--tenant-routes` | Include tenant routing (`/:tenantId` prefix) | `false` |
| `--ai-agent` | Include AI Agent configuration | `false` |
| `--dashboard` | Include Dashboard with widgets | `false` |
| `--mocks` | Include sample module with mock data | `false` |
| `--overwrite` | Overwrite existing files without confirmation | `false` |
| `--help`, `-h` | Show help | — |
| `--version`, `-v` | Show version | — |
| Option | Description | Default |
| ---------------------- | ------------------------------------------------ | ---------------------- |
| `--type <type>` | Project type: `standalone` \| `dynamic-module` ` | _(prompted)_ |
| `--name`, `--app-name` | Application name | Directory name |
| `--package-name` | npm package name | App name (validated) |
| `--module-name` | Initial module name | App name in title case |
| `--base-path` | Base path for the application | `/apps/<name>/` |
| `--tenant-routes` | Include tenant routing (`/:tenantId` prefix) | `false` |
| `--ai-agent` | Include AI Agent configuration | `false` |
| `--dashboard` | Include Dashboard with widgets | `false` |
| `--mocks` | Include sample module with mock data | `false` |
| `--overwrite` | Overwrite existing files without confirmation | `false` |
| `--help`, `-h` | Show help | — |
| `--version`, `-v` | Show version | — |

## Add a Module to Existing Project

From your project root:

```bash
npx create-vc-app add-module <module-name>
npx @vc-shell/create-vc-app add-module <module-name>
```

This will:
Expand Down
5 changes: 3 additions & 2 deletions cli/create-vc-app/src/commands/add-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import fs from "node:fs";
import { toKebabCase, toPascalCase, toSentenceCase, buildTemplateData } from "../engine/helpers.js";
import { renderDir } from "../engine/template.js";
import { addModuleToMain, addMenuItemToBootstrap } from "../engine/codegen.js";
import type { CLIArgs } from "../types.js";

export async function addModuleCommand(args: Record<string, unknown>, templateRoot: string): Promise<void> {
export async function addModuleCommand(args: CLIArgs, templateRoot: string): Promise<void> {
const cwd = process.cwd();
const argModuleName = (args._ as string[])?.[1];
const argModuleName = args._?.[1];

// Validate: is this a vc-shell project?
const pkgPath = path.join(cwd, "package.json");
Expand Down
95 changes: 19 additions & 76 deletions cli/create-vc-app/src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,7 @@ describe("initCommand — standalone", () => {

it("generates without module when --module-name is not provided", async () => {
const _projectName = path.basename(root);
await initCommand(
{ _: [root], type: "standalone", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", overwrite: true }, templateRoot);

const mainTs = readGenerated(root, "src/main.ts");

Expand All @@ -53,10 +50,7 @@ describe("initCommand — standalone", () => {
});

it("generates with module when --module-name is provided", async () => {
await initCommand(
{ _: [root], type: "standalone", "module-name": "Orders", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", "module-name": "Orders", overwrite: true }, templateRoot);

const mainTs = readGenerated(root, "src/main.ts");

Expand All @@ -74,10 +68,7 @@ describe("initCommand — standalone", () => {
});

it("generates with mocks when --mocks is provided", async () => {
await initCommand(
{ _: [root], type: "standalone", mocks: true, overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", mocks: true, overwrite: true }, templateRoot);

const mainTs = readGenerated(root, "src/main.ts");

Expand All @@ -94,10 +85,7 @@ describe("initCommand — standalone", () => {

it("generates with both module and mocks", async () => {
await initCommand(
{ _: [root], type: "standalone", "module-name": "Reviews", mocks: true, overwrite: true } as unknown as Record<
string,
unknown
>,
{ _: [root], type: "standalone", "module-name": "Reviews", mocks: true, overwrite: true },
templateRoot,
);

Expand All @@ -113,10 +101,7 @@ describe("initCommand — standalone", () => {
});

it("includes dashboard when --dashboard is set", async () => {
await initCommand(
{ _: [root], type: "standalone", dashboard: true, overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", dashboard: true, overwrite: true }, templateRoot);

const bootstrap = readGenerated(root, "src/bootstrap.ts");

Expand All @@ -126,10 +111,7 @@ describe("initCommand — standalone", () => {
});

it("excludes dashboard by default in non-interactive mode", async () => {
await initCommand(
{ _: [root], type: "standalone", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", overwrite: true }, templateRoot);

const bootstrap = readGenerated(root, "src/bootstrap.ts");

Expand All @@ -138,10 +120,7 @@ describe("initCommand — standalone", () => {
});

it("includes AI agent config when --ai-agent is set", async () => {
await initCommand(
{ _: [root], type: "standalone", "ai-agent": true, overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", "ai-agent": true, overwrite: true }, templateRoot);

const mainTs = readGenerated(root, "src/main.ts");

Expand All @@ -150,21 +129,15 @@ describe("initCommand — standalone", () => {
});

it("excludes AI agent config by default", async () => {
await initCommand(
{ _: [root], type: "standalone", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", overwrite: true }, templateRoot);

const mainTs = readGenerated(root, "src/main.ts");

expect(mainTs).not.toContain("aiAgent:");
});

it("includes tenant routes when --tenant-routes is set", async () => {
await initCommand(
{ _: [root], type: "standalone", "tenant-routes": true, overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", "tenant-routes": true, overwrite: true }, templateRoot);

const routes = readGenerated(root, "src/router/routes.ts");

Expand All @@ -173,10 +146,7 @@ describe("initCommand — standalone", () => {
});

it("excludes tenant routes by default", async () => {
await initCommand(
{ _: [root], type: "standalone", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", overwrite: true }, templateRoot);

const routes = readGenerated(root, "src/router/routes.ts");

Expand All @@ -186,10 +156,7 @@ describe("initCommand — standalone", () => {
});

it("dashboard adds routes and pages", async () => {
await initCommand(
{ _: [root], type: "standalone", dashboard: true, overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", dashboard: true, overwrite: true }, templateRoot);

const routes = readGenerated(root, "src/router/routes.ts");

Expand All @@ -198,10 +165,7 @@ describe("initCommand — standalone", () => {
});

it("no dashboard route when dashboard is off", async () => {
await initCommand(
{ _: [root], type: "standalone", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", overwrite: true }, templateRoot);

const routes = readGenerated(root, "src/router/routes.ts");

Expand All @@ -219,7 +183,7 @@ describe("initCommand — standalone", () => {
dashboard: true,
mocks: true,
overwrite: true,
} as unknown as Record<string, unknown>,
},
templateRoot,
);

Expand Down Expand Up @@ -255,10 +219,7 @@ describe("initCommand — standalone", () => {
});

it("has no extra blank lines in main.ts", async () => {
await initCommand(
{ _: [root], type: "standalone", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", overwrite: true }, templateRoot);

const mainTs = readGenerated(root, "src/main.ts");

Expand All @@ -279,10 +240,7 @@ describe("initCommand — standalone module locales", () => {
});

it("module pages use $t() for all user-facing strings", async () => {
await initCommand(
{ _: [root], type: "standalone", "module-name": "Orders", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", "module-name": "Orders", overwrite: true }, templateRoot);

const listVue = readGenerated(root, "src/modules/orders/pages/list.vue");
const detailsVue = readGenerated(root, "src/modules/orders/pages/details.vue");
Expand Down Expand Up @@ -311,10 +269,7 @@ describe("initCommand — standalone module locales", () => {
});

it("locales en.json has all required keys", async () => {
await initCommand(
{ _: [root], type: "standalone", "module-name": "Orders", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", "module-name": "Orders", overwrite: true }, templateRoot);

const locales = JSON.parse(readGenerated(root, "src/modules/orders/locales/en.json"));

Expand All @@ -333,10 +288,7 @@ describe("initCommand — standalone module locales", () => {
});

it("module index.ts imports and passes locales", async () => {
await initCommand(
{ _: [root], type: "standalone", "module-name": "Orders", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "standalone", "module-name": "Orders", overwrite: true }, templateRoot);

const indexTs = readGenerated(root, "src/modules/orders/index.ts");

Expand All @@ -358,10 +310,7 @@ describe("initCommand — dynamic-module", () => {
});

it("always generates module even without --module-name", async () => {
await initCommand(
{ _: [root], type: "dynamic-module", overwrite: true } as unknown as Record<string, unknown>,
templateRoot,
);
await initCommand({ _: [root], type: "dynamic-module", overwrite: true }, templateRoot);

// Module files are rendered into src/modules/ (flat, not nested for dynamic-module)
expect(fileExists(root, "src/modules/index.ts")).toBe(true);
Expand All @@ -370,13 +319,7 @@ describe("initCommand — dynamic-module", () => {
});

it("uses provided --module-name", async () => {
await initCommand(
{ _: [root], type: "dynamic-module", "module-name": "Reviews", overwrite: true } as unknown as Record<
string,
unknown
>,
templateRoot,
);
await initCommand({ _: [root], type: "dynamic-module", "module-name": "Reviews", overwrite: true }, templateRoot);

const indexTs = readGenerated(root, "src/modules/index.ts");
expect(indexTs).toContain("defineAppModule");
Expand Down
33 changes: 21 additions & 12 deletions cli/create-vc-app/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import prompts from "prompts";
import pc from "picocolors";
import path from "node:path";
import fs from "node:fs";
import type { ProjectOptions, ProjectType } from "../types.js";
import type { CLIArgs, ProjectOptions, ProjectType } from "../types.js";
import {
isValidPackageName,
toValidPackageName,
Expand All @@ -19,9 +19,13 @@ const PROJECT_TYPES: { title: string; value: ProjectType }[] = [
{ title: "Dynamic Module — remote module loaded by host via Module Federation", value: "dynamic-module" },
];

export async function initCommand(args: Record<string, unknown>, templateRoot: string): Promise<void> {
function isProjectType(value: unknown): value is ProjectType {
return typeof value === "string" && PROJECT_TYPES.some((t) => t.value === value);
}

export async function initCommand(args: CLIArgs, templateRoot: string): Promise<void> {
const cwd = process.cwd();
const argName = (args._ as string[])?.[0] || (args.name as string) || (args["app-name"] as string);
const argName = args._?.[0] || args.name || args["app-name"];
let dir = argName;
const defaultAppName = !dir ? "vc-app" : dir;
const getProjectName = () => (dir === "." ? path.basename(path.resolve()) : dir!);
Expand All @@ -35,32 +39,37 @@ export async function initCommand(args: Record<string, unknown>, templateRoot: s
dir = dir || defaultAppName;
const root = path.resolve(cwd, dir);

if (!isProjectType(args.type)) {
const valid = PROJECT_TYPES.map((t) => t.value).join(", ");
console.error(pc.red(`Unknown project type: "${args.type}". Valid types: ${valid}`));
process.exit(1);
}

if (fs.existsSync(root) && !isDirEmpty(root) && !args.overwrite) {
console.error(pc.red(`Target directory "${dir}" is not empty. Use --overwrite to overwrite.`));
process.exit(1);
}

const projectName = getProjectName();
const projectType = args.type as ProjectType;
const projectType = args.type;

// For standalone, module is opt-in: only generated when --module-name is explicitly provided.
// For dynamic-module, module is always required — default to project name.
const explicitModule = args["module-name"] as string | undefined;
const explicitModule = args["module-name"];
const moduleName =
projectType === "dynamic-module" ? explicitModule || toSentenceCase(projectName) : explicitModule || undefined;

options = {
projectName: toKebabCase(projectName),
packageName:
(args["package-name"] as string) ||
(isValidPackageName(projectName) ? projectName : toValidPackageName(projectName)),
args["package-name"] || (isValidPackageName(projectName) ? projectName : toValidPackageName(projectName)),
projectType,
moduleName,
basePath: (args["base-path"] as string) || toValidBasePath(`/apps/${toKebabCase(projectName)}/`),
tenantRoutes: (args["tenant-routes"] as boolean) || false,
aiAgent: (args["ai-agent"] as boolean) || false,
dashboard: (args.dashboard as boolean) || false,
mocks: (args.mocks as boolean) || false,
basePath: args["base-path"] || toValidBasePath(`/apps/${toKebabCase(projectName)}/`),
tenantRoutes: args["tenant-routes"] || false,
aiAgent: args["ai-agent"] || false,
dashboard: args.dashboard || false,
mocks: args.mocks || false,
};
} else {
// Interactive mode — split into phases so computed defaults are resolved before use
Expand Down
Loading
Loading