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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jobs:
["packages/react-router-core"]="@react-router-modules/core"
["packages/react-router-runtime"]="@react-router-modules/runtime"
["packages/react-router-testing"]="@react-router-modules/testing"
["packages/tanstack-router-cli"]="@tanstack-react-modules/cli"
["packages/tanstack-router-core"]="@tanstack-react-modules/core"
["packages/tanstack-router-runtime"]="@tanstack-react-modules/runtime"
["packages/tanstack-router-testing"]="@tanstack-react-modules/testing"
)

PACKAGES=()
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ jobs:
- 'packages/react-router-testing/src/**'
- 'packages/react-router-testing/package.json'
- 'packages/react-router-testing/README.md'
pkg_tr_cli:
- 'packages/tanstack-router-cli/src/**'
- 'packages/tanstack-router-cli/package.json'
- 'packages/tanstack-router-cli/README.md'
pkg_tr_core:
- 'packages/tanstack-router-core/src/**'
- 'packages/tanstack-router-core/package.json'
- 'packages/tanstack-router-core/README.md'
pkg_tr_runtime:
- 'packages/tanstack-router-runtime/src/**'
- 'packages/tanstack-router-runtime/package.json'
- 'packages/tanstack-router-runtime/README.md'
pkg_tr_testing:
- 'packages/tanstack-router-testing/src/**'
- 'packages/tanstack-router-testing/package.json'
- 'packages/tanstack-router-testing/README.md'

- name: Build dynamic matrix
id: build-matrix
Expand All @@ -86,6 +102,10 @@ jobs:
["pkg_rr_core"]="react-router-core:@react-router-modules/core"
["pkg_rr_runtime"]="react-router-runtime:@react-router-modules/runtime"
["pkg_rr_testing"]="react-router-testing:@react-router-modules/testing"
["pkg_tr_cli"]="tanstack-router-cli:@tanstack-react-modules/cli"
["pkg_tr_core"]="tanstack-router-core:@tanstack-react-modules/core"
["pkg_tr_runtime"]="tanstack-router-runtime:@tanstack-react-modules/runtime"
["pkg_tr_testing"]="tanstack-router-testing:@tanstack-react-modules/testing"
)

CHANGED='${{ steps.filter.outputs.changes }}'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
.turbo/
*.tsbuildinfo
.idea
.test-output
52 changes: 52 additions & 0 deletions packages/tanstack-router-cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# @tanstack-react-modules/cli

Scaffolding CLI for the Reactive modular framework. Creates projects, modules, and stores with full wiring.

## Commands

```bash
reactive init <name> --scope @myorg --module dashboard # New project
reactive create module <name> --route billing # New module
reactive create store <name> # New Zustand store
```

All commands support interactive (prompts) and non-interactive (flags) modes. See the [main README](../../README.md#cli-reference) for full documentation.

## Development

Requires Node.js 24+.

```bash
pnpm build # Compile TypeScript
pnpm dev # Watch mode
```

## Testing

### Unit tests (cli-testlab)

Tests CLI commands by executing them as child processes and asserting on output and generated files.

```bash
pnpm test
```

### E2E tests (Playwright)

Smoke tests that validate the full framework end-to-end: scaffold a project via CLI, start the dev server, and interact with the served UI using Playwright.

```bash
pnpm test:e2e:setup # Scaffold project, build framework, install deps
pnpm test:e2e:server # Start vite dev server on port 5188 (run in background)
pnpm test:e2e # Run Playwright tests against the running server
```

The setup script uses `link:` overrides to point `@tanstack-react-modules/core` and `@tanstack-react-modules/runtime` to the local built packages (since they aren't published to npm yet).

To re-scaffold from scratch:

```bash
pnpm clean # Remove dist + test artifacts
pnpm build # Rebuild CLI
pnpm test:e2e:setup # Re-scaffold
```
46 changes: 46 additions & 0 deletions packages/tanstack-router-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@tanstack-react-modules/cli",
"version": "1.0.0",
"repository": {
"type": "git",
"url": "git+https://github.com/kibertoad/modular-react.git",
"directory": "packages/tanstack-router-cli"
},
"bin": {
"reactive": "./dist/cli.js"
},
"files": [
"dist"
],
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:e2e:setup": "node scripts/e2e-setup.ts",
"test:e2e:server": "cd .e2e-output/smoke-app/shell && node node_modules/vite/bin/vite.js --port 5188",
"test:e2e": "playwright test smoke.e2e.ts",
"clean": "rimraf dist .test-output .e2e-output",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^1.1.0",
"citty": "^0.2.1",
"magicast": "^0.5.2",
"pathe": "^2.0.3"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "^25.5.0",
"cli-testlab": "^6.0.1",
"typescript": "^6.0.2",
"vitest": "^4.1.0"
},
"engines": {
"node": ">=24.0.0"
}
}
12 changes: 12 additions & 0 deletions packages/tanstack-router-cli/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from "@playwright/test";

export default defineConfig({
testDir: "./test",
testMatch: "**/*.e2e.ts",
timeout: 30_000,
retries: 0,
use: {
headless: true,
baseURL: "http://localhost:5188",
},
});
41 changes: 41 additions & 0 deletions packages/tanstack-router-cli/scripts/e2e-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env node

import { execSync } from "node:child_process";
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";

const CLI = resolve(import.meta.dirname, "..", "dist", "cli.js");
const REPO_ROOT = resolve(import.meta.dirname, "..", "..", "..");
const TMP = resolve(import.meta.dirname, "..", ".e2e-output");
const PROJECT_DIR = resolve(TMP, "smoke-app");

function exec(cmd: string, cwd?: string) {
execSync(cmd, { cwd, stdio: "inherit", timeout: 120_000 });
}

// Skip if already set up
if (existsSync(resolve(PROJECT_DIR, "node_modules"))) {
console.log('E2E project already exists. Run "pnpm clean" first to re-scaffold.');
process.exit(0);
}

mkdirSync(TMP, { recursive: true });

exec(`node ${CLI} init smoke-app --scope @smoke --module dashboard`, TMP);

// Build framework packages
exec("pnpm -r run build", REPO_ROOT);

// Override @tanstack-react-modules/* to local (not yet on npm)
const rootPkg = JSON.parse(readFileSync(resolve(PROJECT_DIR, "package.json"), "utf-8"));
rootPkg.pnpm = {
overrides: {
"@tanstack-react-modules/core": `link:${resolve(REPO_ROOT, "packages", "core")}`,
"@tanstack-react-modules/runtime": `link:${resolve(REPO_ROOT, "packages", "registry")}`,
},
};
writeFileSync(resolve(PROJECT_DIR, "package.json"), JSON.stringify(rootPkg, null, 2));

exec("pnpm install", PROJECT_DIR);

console.log("E2E setup complete.");
31 changes: 31 additions & 0 deletions packages/tanstack-router-cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env node

import { defineCommand, runMain } from "citty";
import init from "./commands/init.js";
import createModule from "./commands/create-module.js";
import createStore from "./commands/create-store.js";

const create = defineCommand({
meta: {
name: "create",
description: "Create a new module or store",
},
subCommands: {
module: createModule,
store: createStore,
},
});

const main = defineCommand({
meta: {
name: "reactive",
version: "0.1.0",
description: "Reactive framework CLI",
},
subCommands: {
init,
create,
},
});

runMain(main);
159 changes: 159 additions & 0 deletions packages/tanstack-router-cli/src/commands/create-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { defineCommand } from "citty";
import * as p from "@clack/prompts";
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { resolve } from "pathe";
import { resolveProject } from "../utils/resolve-project.js";
import { detectScope } from "../utils/detect-scope.js";
import { addModuleToMain, addModuleToShellPackageJson } from "../utils/transform.js";
import {
modulePackageJson,
moduleTsconfig,
moduleDescriptor,
modulePage,
moduleListPage,
moduleTest,
} from "../templates/module.js";

export default defineCommand({
meta: {
name: "module",
description: "Create a new module",
},
args: {
name: {
type: "positional",
description: "Module name",
required: false,
},
route: {
type: "string",
description: "Route path (defaults to module name)",
},
"nav-group": {
type: "string",
description: "Navigation group",
},
},
async run({ args }) {
const project = resolveProject();
const scope = detectScope(project.root);

const isNonInteractive = Boolean(args.name);

if (!isNonInteractive) {
p.intro("Create a new module");
}

const name =
args.name ||
((await p.text({
message: "Module name",
placeholder: "billing",
validate: (v) => (!v ? "Required" : undefined),
})) as string);

if (p.isCancel(name)) {
p.cancel("Cancelled");
process.exit(0);
}

const moduleDir = resolve(project.modulesDir, name);
if (existsSync(moduleDir)) {
const msg = `Module "${name}" already exists at ${moduleDir}`;
if (isNonInteractive) {
console.error(msg);
process.exit(1);
}
p.cancel(msg);
process.exit(1);
}

const route =
args.route ||
(isNonInteractive
? name
: ((await p.text({
message: "Route path",
defaultValue: name,
placeholder: name,
})) as string));

if (p.isCancel(route)) {
p.cancel("Cancelled");
process.exit(0);
}

const navGroup =
args["nav-group"] ||
(isNonInteractive
? undefined
: ((await p.text({
message: "Navigation group (optional)",
placeholder: "leave empty for none",
})) as string)) ||
undefined;

if (p.isCancel(navGroup)) {
p.cancel("Cancelled");
process.exit(0);
}

const pageName = toPascalCase(name) + "Dashboard";
const listPageName = toPascalCase(name) + "List";
const importName = toCamelCase(name);

// Scaffold module directory
mkdirSync(resolve(moduleDir, "src", "pages"), { recursive: true });
mkdirSync(resolve(moduleDir, "src", "__tests__"), { recursive: true });
writeFileSync(resolve(moduleDir, "package.json"), modulePackageJson({ scope, name }));
writeFileSync(resolve(moduleDir, "tsconfig.json"), moduleTsconfig());
writeFileSync(
resolve(moduleDir, "src", "index.ts"),
moduleDescriptor({ scope, name, route, pageName, listPageName, navGroup }),
);
writeFileSync(
resolve(moduleDir, "src", "pages", `${pageName}.tsx`),
modulePage({ scope, pageName, moduleLabel: toPascalCase(name), moduleName: name }),
);
writeFileSync(
resolve(moduleDir, "src", "pages", `${listPageName}.tsx`),
moduleListPage({ scope, pageName: listPageName, moduleLabel: toPascalCase(name) }),
);
writeFileSync(
resolve(moduleDir, "src", "__tests__", `${name}.test.ts`),
moduleTest({ scope, name, importName, route, pageName }),
);

// Wire into shell
addModuleToShellPackageJson(project.shellDir, { scope, moduleName: name });
addModuleToMain(project.shellDir, { scope, moduleName: name, importName });

if (!isNonInteractive) {
p.note(
[
`Module: modules/${name}/`,
`Package: ${scope}/${name}-module`,
`Route: /${route}`,
"",
"Run pnpm install to link the new package.",
].join("\n"),
"Created",
);
p.outro("Done!");
} else {
console.log(`Module "${name}" created at modules/${name}/`);
}
},
});

function toPascalCase(str: string): string {
return str
.split(/[-_]/)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join("");
}

function toCamelCase(str: string): string {
const pascal = toPascalCase(str);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
}
Loading
Loading