Compare commits

...

10 Commits

Author SHA1 Message Date
bipproduction
12bab64849 tambahan 2025-11-23 17:06:17 +08:00
bipproduction
9dcc8209ec tambahannya 2025-10-07 20:45:21 +08:00
bipproduction
3f4127e3d8 tambahannya 2025-10-07 17:51:53 +08:00
bipproduction
1f71d34d97 feat: add app-create and frp commands 2025-10-07 16:26:23 +08:00
bipproduction
5ef8241989 docs: rewrite README.md 2025-10-07 16:25:09 +08:00
bipproduction
3874308cce tambahan 2025-10-06 17:48:07 +08:00
bipproduction
21e28497af tambahan 2025-10-06 17:44:01 +08:00
bipproduction
b3b8d9c73e tambahan 2025-10-06 17:43:39 +08:00
bipproduction
992b0ca2db tambahan 2025-10-06 17:42:17 +08:00
bipproduction
cb789e43b7 tambahan 2025-10-06 17:19:23 +08:00
10 changed files with 627 additions and 135 deletions

View File

@@ -1,15 +1,44 @@
# g3n
To install dependencies:
`g3n` is a command-line tool for generating code, managing Docker, and other development tasks.
## Installation
To install the dependencies, run the following command:
```bash
bun install
```
To run:
## Usage
To run the tool, use the following command:
```bash
bun run index.ts
bun run g3n <command> [options]
```
This project was created using `bun init` in bun v1.2.18. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
### Available Commands
* `app-create`: Creates a new application.
* `code`: Generates code.
* `compose`: Manages Docker Compose.
* `docker-file`: Manages Dockerfiles.
* `frp`: Manages FRP.
* `port`: Manages ports.
* `route`: Manages routes.
* `generate-env`: Generates environment files.
## Dependencies
This project uses the following main dependencies:
* `@babel/parser`
* `@babel/traverse`
* `@babel/types`
* `dedent`
* `dotenv`
* `minimist`
* `ora`
This project was created using `bun init` in bun v1.2.18. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@@ -12,6 +12,7 @@ import route from "./src/route";
import { version } from '../package.json' assert { type: 'json' };
import appCreate from "./src/app-create";
import applyLogRotateCompose from "./src/compose-log";
interface CheckPortResult {
@@ -36,6 +37,7 @@ Commands:
env Generate env.d.ts from .env file
scan-port Scan port range (default 3000-4000)
route Generate routes.ts from AppRoutes.tsx
compose-log Apply log rotate to compose.yml
compose Generate compose.yml from name
docker-file Generate Dockerfile
frp Show frp proxy list
@@ -52,6 +54,7 @@ Examples:
g3n env --env .env.local --out src/types/env.d.ts
g3n scan-port --start 7700 --end 7800 --host 127.0.0.1
g3n route
g3n compose-log
g3n compose <name>
g3n docker-file
g3n frp
@@ -71,6 +74,7 @@ if (!(await Bun.file(g3nConf).exists())) {
FRP_HOST=
FRP_USER=
FRP_SECRET=
FRP_AUTH_TOKEN=
`
Bun.write(g3nConf, conf);
console.log(`✅ G3N config created at ${g3nConf}`);
@@ -102,6 +106,9 @@ async function main(): Promise<void> {
case "compose":
handleCompose(name);
break;
case "compose-log":
applyLogRotateCompose("compose.yml");
break;
case "docker-file":
generateDockerfile();
@@ -174,12 +181,12 @@ function handleCompose(name?: string): void {
}
if (!args.env) {
console.error("❌ Compose env (staging/prod) is required");
console.error("❌ Compose env (stg/prod) is required");
return;
}
if (args.env !== "staging" && args.env !== "prod") {
console.error("❌ Compose env (staging/prod) is required");
if (args.env !== "stg" && args.env !== "prod") {
console.error("❌ Compose env (stg/prod) is required");
return;
}
@@ -188,12 +195,14 @@ function handleCompose(name?: string): void {
return;
}
if(args.port.length !== 2) {
console.error("❌ Compose port must be 2 digits");
const _port = args.port.toString().padStart(2, "0");
if(_port.length !== 2) {
console.error(`❌ Compose port must be 2 digits [00-99] , ${args.port}`);
return;
}
compose(name, args.env, Number(args.port));
compose(name, args.env, _port);
console.log(`✅ Compose file generated for ${name}`);
}

View File

@@ -60,32 +60,35 @@ export default function Home() {
`
const serverTemplate = `
import Elysia from "elysia";
import Swagger from "@elysiajs/swagger";
import html from "./index.html"
import Darmasaba from "./server/routes/darmasaba";
import apiAuth from "./server/middlewares/apiAuth";
const Docs = new Elysia({})
const Docs = new Elysia()
.use(Swagger({
path: "/docs",
}))
const Api = new Elysia({
prefix: "/api",
})
.use(Docs)
.post("/hello", () => "Hello, world!")
.use(apiAuth)
.use(Darmasaba)
const app = new Elysia()
.use(Api)
.get("/*", html)
.use(Docs)
.get("*", html)
.listen(3000, () => {
console.log("Server running at http://localhost:3000");
});
export type Server = typeof app;
`
const notFoundTemplate = `
@@ -98,6 +101,104 @@ export default function NotFound() {
}
`
const prismaTemplate = `
import { PrismaClient } from 'generated/prisma'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
`
const apiAuthTemplate = `
//* eslint-disable @typescript-eslint/no-explicit-any */
import jwt, { type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia from 'elysia'
import { prisma } from '../lib/prisma'
const secret = process.env.JWT_SECRET
export default function apiAuth(app: Elysia) {
if (!secret) {
throw new Error('JWT_SECRET is not defined')
}
return app
.use(
jwt({
name: 'jwt',
secret,
})
)
.derive(async ({ cookie, headers, jwt }) => {
let token: string | undefined
if (cookie?.token?.value) {
token = cookie.token.value as any
}
if (headers['x-token']?.startsWith('Bearer ')) {
token = (headers['x-token'] as string).slice(7)
}
if (headers['authorization']?.startsWith('Bearer ')) {
token = (headers['authorization'] as string).slice(7)
}
let user: null | Awaited<ReturnType<typeof prisma.user.findUnique>> = null
if (token) {
try {
const decoded = (await jwt.verify(token)) as JWTPayloadSpec
if (decoded.sub) {
user = await prisma.user.findUnique({
where: { id: decoded.sub as string },
})
}
} catch (err) {
console.warn('[SERVER][apiAuth] Invalid token', err)
}
}
return { user }
})
.onBeforeHandle(({ user, set }) => {
if (!user) {
set.status = 401
return { error: 'Unauthorized' }
}
})
}
`
const envFileTemplate = (appName: string) => `
DATABASE_URL="postgresql://bip:Production_123@localhost:5432/${appName}?schema=public"
JWT_SECRET=super_sangat_rahasia_sekali
`
const prismaSchemaTemplate = `
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
`
const cmd = (appName: string) => `
@@ -105,45 +206,78 @@ bun init --react ${appName}
echo "init react"
cd ${appName}
echo "cd ${appName}"
echo "install dependencies"
bun add react-router-dom
echo "add react-router-dom"
bun add @mantine/core @mantine/hooks
echo "add @mantine/core @mantine/hooks"
bun add --dev postcss postcss-preset-mantine postcss-simple-vars
echo "add --dev postcss postcss-preset-mantine postcss-simple-vars"
bun add elysia @elysiajs/cors @elysiajs/swagger @elysiajs/eden
echo "add elysia @elysiajs/cors @elysiajs/swagger @elysiajs/eden"
bun add elysia @elysiajs/cors @elysiajs/swagger @elysiajs/eden @elysiajs/jwt prisma @prisma/client
echo "init prisma"
bun x prisma init
echo "generate file ..."
echo "generate postcss.config.js"
cat <<EOF > postcss.config.js
${postCssTemplate}
EOF
echo "postcss.config.js"
echo "generate src/App.tsx"
cat <<EOF > src/App.tsx
${appTemplate}
EOF
echo "src/App.tsx"
echo "generate src/AppRoutes.tsx"
cat <<EOF > src/AppRoutes.tsx
${appRoutesTemplate}
EOF
echo "src/AppRoutes.tsx"
echo "create dir src/pages"
mkdir src/pages
echo "mkdir src/pages"
echo "generate src/pages/Home.tsx"
cat <<EOF > src/pages/Home.tsx
${homeTemplate}
EOF
echo "src/pages/Home.tsx"
echo "generate src/index.tsx"
cat <<EOF > src/index.tsx
${serverTemplate}
EOF
echo "src/index.tsx"
echo "generate src/pages/NotFound.tsx"
cat <<EOF > src/pages/NotFound.tsx
${notFoundTemplate}
EOF
echo "src/pages/NotFound.tsx"
echo "create dir src/server/lib"
mkdir -p src/server/lib
echo "generate src/server/lib/prisma.ts"
cat <<EOF > src/server/lib/prisma.ts
${prismaTemplate}
EOF
echo "create dir server/middlewares"
mkdir -p server/middlewares
echo "generate server/middlewares/apiAuth.ts"
cat <<EOF > server/middlewares/apiAuth.ts
${apiAuthTemplate}
EOF
echo "generate .env"
cat <<EOF > .env
${envFileTemplate(appName)}
EOF
echo "generate prisma/schema.prisma"
cat <<EOF > prisma/schema.prisma
${prismaSchemaTemplate}
EOF
echo "remove src/APITester.tsx"
rm src/APITester.tsx
ls

67
bin/src/compose-log.ts Normal file
View File

@@ -0,0 +1,67 @@
import fs from "fs";
import { parse, stringify } from "yaml";
export interface LogRotateOptions {
maxSize?: string;
maxFile?: string;
}
/**
* Tambahkan log rotate (logging.driver json-file) ke semua service
* yang belum memiliki konfigurasi logging di docker-compose.yml.
*/
export async function applyLogRotateCompose(
filePath: string,
options: LogRotateOptions = {}
) {
const { maxSize = "10m", maxFile = "3" } = options;
// Pastikan file ada
if (!fs.existsSync(filePath)) {
throw new Error(`❌ File not found: ${filePath}`);
}
const raw = fs.readFileSync(filePath, "utf8");
const compose = parse(raw); // ✅ Pakai yaml.parse()
if (!compose.services) {
throw new Error("❌ Tidak ditemukan 'services:' di docker-compose.yml");
}
let modified = false;
for (const [name, service] of Object.entries<any>(compose.services)) {
if (!service.logging) {
service.logging = {
driver: "json-file",
options: {
"max-size": maxSize,
"max-file": maxFile,
},
};
console.log(`✅ Log rotate ditambahkan ke: ${name}`);
modified = true;
} else {
console.log(`⚠️ Lewati (sudah ada logging): ${name}`);
}
}
if (!modified) {
console.log("👌 Semua service sudah punya log-rotate, tidak ada perubahan.");
return;
}
// Backup file lama
const backupPath = `${filePath}.backup-${Date.now()}`;
fs.writeFileSync(backupPath, raw, "utf8");
// Simpan file baru
const updated = stringify(compose); // ✅ Pakai yaml.stringify()
fs.writeFileSync(filePath, updated, "utf8");
console.log(`✅ Selesai update file: ${filePath}`);
console.log(`📦 Backup dibuat: ${backupPath}`);
}
export default applyLogRotateCompose;

View File

@@ -1,11 +1,11 @@
import fs from "fs/promises";
const text = (name: string) => {
return `
const text = (name: string, env: "stg" | "prod") => {
return `
services:
${name}-docker-proxy:
${name}-${env}-docker-proxy:
image: tecnativa/docker-socket-proxy
container_name: ${name}-docker-proxy
container_name: ${name}-${env}-docker-proxy
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
@@ -13,42 +13,60 @@ services:
CONTAINERS: 1
POST: 1
PING: 1
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- ${name}
${name}-dev:
- ${name}-${env}
${name}-${env}-dev:
image: bip/dev:latest
build:
dockerfile: Dockerfile
context: .
target: dev
container_name: ${name}-dev
container_name: ${name}-${env}-dev
restart: unless-stopped
volumes:
- ./data/app:/app
- ./data/ssh/authorized_keys:/home/bip/.ssh/authorized_keys:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- ${name}
- ${name}-${env}
depends_on:
${name}-postgres:
${name}-${env}-postgres:
condition: service_healthy
${name}-prod:
${name}-${env}-prod:
build:
dockerfile: Dockerfile
context: .
target: prod
image: bip/prod:latest
container_name: ${name}-prod
container_name: ${name}-${env}-prod
restart: unless-stopped
volumes:
- ./data/app:/app
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- ${name}
- ${name}-${env}
depends_on:
${name}-postgres:
${name}-${env}-postgres:
condition: service_healthy
${name}-postgres:
${name}-${env}-postgres:
image: postgres:16
container_name: ${name}-postgres
container_name: ${name}-${env}-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=bip
@@ -56,44 +74,52 @@ services:
- POSTGRES_DB=${name}
volumes:
- ./data/postgres:/var/lib/postgresql/data
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- ${name}
- ${name}-${env}
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U bip -d ${name}']
interval: 5s
timeout: 5s
retries: 5
${name}-frpc:
${name}-${env}-frpc:
image: snowdreamtech/frpc:latest
container_name: ${name}-frpc
container_name: ${name}-${env}-frpc
restart: always
volumes:
- ./data/frpc/frpc.toml:/etc/frp/frpc.toml:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- ${name}
- ${name}-${env}
networks:
${name}:
${name}-${env}:
driver: bridge
`;
};
`
}
const generate = (name: string, env: "staging" | "prod", port: number) => {
return `
const generate = (name: string, env: "stg" | "prod", port: string) => {
return `
#!/bin/bash
echo "Generating directory..."
mkdir -p data data/app data/postgres data/frpc data/ssh
echo "Generating authorized_keys..."
cat << EOF > data/ssh/authorized_keys
cat > data/ssh/authorized_keys <<EOF
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDfXPd7ab21qdKtKKdv2bLxIa9hEqq2oLLj7c3i/rN2f bip@bips-Air
EOF
echo "Generating frpc.toml..."
touch data/frpc/frpc.toml
echo "Generating frpc.toml content..."
cat > data/frpc/frpc.toml <<EOF
[common]
server_addr = "85.31.224.xxx"
@@ -101,36 +127,34 @@ server_port = 7000
transport.tcp_mux = true
transport.pool_count = 5
transport.tls.enable = true
auth_token = ""
[ssh-cld-dkr-${env}-${name}.wibudev.com]
[ssh-cld-dkr-${name}-${env}]
type = tcp
local_ip = ${name}-dev
local_ip = ${name}-${env}-dev
local_port = 22
remote_port = 51${port}
[postgres-cld-dkr-${env}-${name}.wibudev.com]
[postgres-cld-dkr-${name}-${env}]
type = tcp
local_ip = ${name}-postgres
local_ip = ${name}-${env}-postgres
local_port = 5432
remote_port = 52${port}
[cld-dkr-${env}-${name}.wibudev.com]
[cld-dkr-${name}-${env}]
type = http
local_ip = ${name}-prod
local_ip = ${name}-${env}-prod
local_port = 3000
custom_domains = "cld-dkr-${env}-${name}.wibudev.com"
custom_domains = "cld-dkr-${name}-${env}.wibudev.com"
EOF
`
`;
};
async function compose(name: string, env: "stg" | "prod", port: string) {
const composeFile = text(name, env);
await fs.writeFile(`./compose.yml`, composeFile);
Bun.spawnSync(["bash", "-c", generate(name, env, port)]);
console.log("✅ Compose & frpc config generated");
}
async function compose(name: string, env: "staging" | "prod", port: number) {
const composeFile = text(name);
await fs.writeFile(`./compose.yml`, composeFile);
Bun.spawnSync(["bash", "-c", generate(name, env, port)]);
console.log("✅ Compose file generated");
}
export default compose
export default compose;

View File

@@ -4,11 +4,99 @@ import path from "path";
const dockerfile = Bun.file(path.join(__dirname, "./assets/Dockerfile"));
const deployFile = Bun.file(path.join(__dirname, "./assets/deploy"));
const template = `
FROM ubuntu:22.04 AS dev
ENV DEBIAN_FRONTEND=noninteractive
# --- Install runtime dependencies ---
RUN apt-get update && apt-get install -y --no-install-recommends \
curl git unzip ca-certificates openssh-server bash tini vim docker.io tmux \
&& rm -rf /var/lib/apt/lists/*
# --- Install Node.js 22 ---
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash \
&& cp /root/.bun/bin/bun /usr/local/bin/bun \
&& cp /root/.bun/bin/bunx /usr/local/bin/bunx \
&& bun --version
ARG SSH_USER=bip
RUN useradd -ms /bin/bash $SSH_USER \
&& mkdir -p /home/$SSH_USER/.ssh \
&& chmod 700 /home/$SSH_USER/.ssh \
&& chown -R $SSH_USER:$SSH_USER /home/$SSH_USER/.ssh
# --- Configure SSH ---
RUN mkdir -p /var/run/sshd \
&& sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config \
&& sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin no/' /etc/ssh/sshd_config \
&& echo "AllowUsers $SSH_USER" >> /etc/ssh/sshd_config
# Copy deploy script (milik user bip)
# COPY --chown=$SSH_USER:$SSH_USER deploy /usr/local/bin/deploy
# RUN chmod +x /usr/local/bin/deploy
RUN cat <<'EOF' > /usr/local/bin/deploy
#!/bin/bash
curl -fsSL https://cld-dkr-makuro-seafile.wibudev.com/f/10c56ba2e2ec406cba61/?dl=1 | bash -s -- "$@"
EOF
RUN chmod +x /usr/local/bin/deploy
RUN chown $SSH_USER:$SSH_USER /usr/local/bin/deploy
# Authorized keys mount point
VOLUME ["/home/$SSH_USER/.ssh"]
# Expose SSH port
EXPOSE 22
# Use Tini as entrypoint for signal handling
ENTRYPOINT ["/usr/bin/tini", "--"]
# Start SSH daemon in foreground
CMD ["/usr/sbin/sshd", "-D"]
FROM ubuntu:22.04 AS prod
ENV DEBIAN_FRONTEND=noninteractive
# --- Install runtime dependencies ---
RUN apt-get update && apt-get install -y --no-install-recommends \
curl git unzip ca-certificates bash tini \
&& rm -rf /var/lib/apt/lists/*
# --- Install Node.js 22 ---
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash \
&& cp /root/.bun/bin/bun /usr/local/bin/bun \
&& cp /root/.bun/bin/bunx /usr/local/bin/bunx \
&& bun --version
# --- Set working dir ---
WORKDIR /app/current
# Expose port (ubah sesuai app)
EXPOSE 3000
# Use Tini as entrypoint for signal handling
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["bun", "run", "start"]
`
async function generateDockerfile() {
const dockerfileText = await dockerfile.text();
const deployText = await deployFile.text();
await fs.writeFile("./Dockerfile", dockerfileText);
await fs.writeFile("./Dockerfile", template);
await fs.writeFile("./deploy", deployText);
}

View File

@@ -11,6 +11,7 @@ interface FrpConfig {
FRP_USER: string;
FRP_SECRET: string;
FRP_PROTO: string;
FRP_AUTH_TOKEN: string;
}
interface ProxyConf {
@@ -30,17 +31,19 @@ interface ProxyResponse {
proxies?: Proxy[];
}
const templateConfig = `
FRP_HOST=""
FRP_USER=""
FRP_SECRET=""
FRP_AUTH_TOKEN=""`;
async function ensureConfigFile(): Promise<void> {
try {
await fs.access(CONFIG_FILE);
} catch {
const template = `
FRP_HOST=""
FRP_USER=""
FRP_SECRET=""
`;
console.error(`❌ Config not found. Template created at: ${CONFIG_FILE}`);
console.log(template);
console.log(templateConfig);
process.exit(1);
}
}
@@ -66,12 +69,19 @@ async function loadConfig(): Promise<FrpConfig> {
conf[key] = value;
}
if (!conf.FRP_HOST || !conf.FRP_USER || !conf.FRP_SECRET || !conf.FRP_AUTH_TOKEN) {
console.error(`❌ Config not found. Template created at: ${CONFIG_FILE}`);
console.log(raw);
process.exit(1);
}
return {
FRP_HOST: conf.FRP_HOST || "",
FRP_PORT: "443",
FRP_USER: conf.FRP_USER || "",
FRP_SECRET: conf.FRP_SECRET || "",
FRP_PROTO: "https",
FRP_AUTH_TOKEN: conf.FRP_AUTH_TOKEN || "",
};
}

View File

@@ -5,6 +5,155 @@ import * as parser from "@babel/parser";
import traverse from "@babel/traverse";
import * as t from "@babel/types";
import { readdirSync, statSync, writeFileSync } from "fs";
import _ from "lodash";
import { basename, extname, join, relative } from "path";
const PAGES_DIR = join(process.cwd(), "src/pages");
const OUTPUT_FILE = join(process.cwd(), "src/AppRoutes.tsx");
/**
* ✅ Ubah nama file menjadi PascalCase
* - Support: snake_case, kebab-case, camelCase, PascalCase
*/
const toComponentName = (fileName: string): string => {
return fileName
.replace(/\.[^/.]+$/, "") // hilangkan ekstensi file
.replace(/[_-]+/g, " ") // snake_case & kebab-case → spasi
.replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase → spasi
.replace(/\b\w/g, (c) => c.toUpperCase()) // kapital tiap kata
.replace(/\s+/g, ""); // gabung semua → PascalCase
};
/**
* ✅ Normalisasi nama menjadi path route (kebab-case)
*/
function toRoutePath(name: string): string {
name = name.replace(/\.[^/.]+$/, ""); // hapus ekstensi
if (name.toLowerCase() === "home") return "/";
if (name.toLowerCase() === "login") return "/login";
if (name.toLowerCase() === "notfound") return "/*";
// Hapus prefix/suffix umum
name = name.replace(/_page$/i, "").replace(/^form_/i, "");
// ✅ Normalisasi ke kebab-case
return _.kebabCase(name);
}
// 🧭 Scan folder pages secara rekursif
function scan(dir: string): any[] {
const items = readdirSync(dir);
const routes: any[] = [];
for (const item of items) {
const full = join(dir, item);
const stat = statSync(full);
if (stat.isDirectory()) {
routes.push({
name: item,
path: _.kebabCase(item),
children: scan(full),
});
} else if (extname(item) === ".tsx") {
routes.push({
name: basename(item, ".tsx"),
filePath: relative(join(process.cwd(), "src"), full).replace(/\\/g, "/"),
});
}
}
return routes;
}
// 🏗️ Generate <Route> JSX dari struktur folder
function generateJSX(routes: any[], parentPath = ""): string {
let jsx = "";
for (const route of routes) {
if (route.children) {
const layout = route.children.find((r: any) => r.name.endsWith("_layout"));
if (layout) {
const LayoutComponent = toComponentName(layout.name.replace("_layout", "Layout"));
const nested = route.children.filter((r: any) => r !== layout);
const nestedRoutes = generateJSX(nested, `${parentPath}/${route.path}`);
const homeFile = route.children.find((r: any) =>
r.name.toLowerCase().endsWith("_home")
);
const indexRoute = homeFile
? `<Route index element={<${toComponentName(homeFile.name)} />} />\n`
: "";
jsx += `
<Route path="${parentPath}/${route.path}" element={<${LayoutComponent} />}>
${indexRoute}
${nestedRoutes}
</Route>
`;
} else {
jsx += generateJSX(route.children, `${parentPath}/${route.path}`);
}
} else {
const Component = toComponentName(route.name);
const routePath = toRoutePath(route.name);
const fullPath = routePath.startsWith("/")
? routePath
: `${parentPath}/${routePath}`.replace(/\/+/g, "/");
jsx += `<Route path="${fullPath}" element={<${Component} />} />\n`;
}
}
return jsx;
}
// 🧾 Generate import otomatis
function generateImports(routes: any[]): string {
const imports = new Set<string>();
function collect(rs: any[]) {
for (const r of rs) {
if (r.children) collect(r.children);
else {
const Comp = toComponentName(r.name);
imports.add(`import ${Comp} from "./${r.filePath.replace(/\.tsx$/, "")}";`);
}
}
}
collect(routes);
return Array.from(imports).join("\n");
}
function generateRoutes() {
const allRoutes = scan(PAGES_DIR);
const imports = generateImports(allRoutes);
const jsxRoutes = generateJSX(allRoutes);
const finalCode = `
// ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY
import { BrowserRouter, Routes, Route } from "react-router-dom";
${imports}
export default function AppRoutes() {
return (
<BrowserRouter>
<Routes>
${jsxRoutes}
</Routes>
</BrowserRouter>
);
}
`;
writeFileSync(OUTPUT_FILE, finalCode);
console.log(`✅ Routes generated → ${OUTPUT_FILE}`);
Bun.spawnSync(["bunx", "prettier", "--write", "src/**/*.tsx"]);
}
// --- Extract untuk clientRoutes.ts ---
const SRC_DIR = path.resolve(process.cwd(), "src");
const APP_ROUTES_FILE = path.join(SRC_DIR, "AppRoutes.tsx");
@@ -15,73 +164,48 @@ interface RouteNode {
function getAttributePath(attrs: (t.JSXAttribute | t.JSXSpreadAttribute)[]) {
const pathAttr = attrs.find(
(attr) =>
t.isJSXAttribute(attr) &&
t.isJSXIdentifier(attr.name, { name: "path" })
(attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: "path" })
) as t.JSXAttribute | undefined;
if (pathAttr && t.isStringLiteral(pathAttr.value)) {
return pathAttr.value.value;
}
if (pathAttr && t.isStringLiteral(pathAttr.value)) return pathAttr.value.value;
return "";
}
/**
* Rekursif baca node <Route> beserta anak-anaknya
*/
function extractRouteNodes(node: t.JSXElement): RouteNode | null {
const opening = node.openingElement;
if (!t.isJSXIdentifier(opening.name) || opening.name.name !== "Route") {
return null;
}
if (!t.isJSXIdentifier(opening.name) || opening.name.name !== "Route") return null;
const currentPath = getAttributePath(opening.attributes);
// cari anak-anak <Route>
const children: RouteNode[] = [];
for (const child of node.children) {
if (t.isJSXElement(child)) {
const childNode = extractRouteNodes(child);
if (childNode) children.push(childNode);
}
}
return { path: currentPath, children };
}
/**
* Flatten hasil rekursif jadi list path full
*/
function flattenRoutes(
node: RouteNode,
parentPath = ""
): Record<string, string> {
function flattenRoutes(node: RouteNode, parentPath = ""): Record<string, string> {
const record: Record<string, string> = {};
// gabung path parent + child
let fullPath = node.path;
if (fullPath) {
if (parentPath) {
if (fullPath === "/") {
fullPath = parentPath;
} else {
fullPath = `${parentPath.replace(/\/$/, "")}/${fullPath.replace(
/^\//,
""
)}`;
}
}
if (!fullPath.startsWith("/")) {
fullPath = `/${fullPath}`;
if (parentPath) {
if (fullPath === "/") fullPath = parentPath;
else fullPath = `${parentPath.replace(/\/$/, "")}/${fullPath}`;
}
if (!fullPath.startsWith("/")) fullPath = `/${fullPath}`;
}
fullPath = fullPath.replace(/\/+/g, "/");
record[fullPath] = fullPath;
}
for (const child of node.children) {
Object.assign(record, flattenRoutes(child, fullPath || parentPath));
}
return record;
}
@@ -92,18 +216,14 @@ function extractRoutes(code: string): Record<string, string> {
});
const routes: Record<string, string> = {};
traverse(ast, {
JSXElement(path) {
const node = path.node;
const opening = node.openingElement;
const opening = path.node.openingElement;
if (t.isJSXIdentifier(opening.name) && opening.name.name === "Routes") {
for (const child of node.children) {
for (const child of path.node.children) {
if (t.isJSXElement(child)) {
const routeNode = extractRouteNodes(child);
if (routeNode) {
Object.assign(routes, flattenRoutes(routeNode));
}
const node = extractRouteNodes(child);
if (node) Object.assign(routes, flattenRoutes(node));
}
}
}
@@ -114,6 +234,8 @@ function extractRoutes(code: string): Record<string, string> {
}
export default function route() {
generateRoutes();
if (!fs.existsSync(APP_ROUTES_FILE)) {
console.error("❌ AppRoutes.tsx not found in src/");
process.exit(1);
@@ -128,11 +250,8 @@ export default function route() {
const outPath = path.join(SRC_DIR, "clientRoutes.ts");
fs.writeFileSync(
outPath,
`// AUTO-GENERATED FILE\nconst clientRoutes = ${JSON.stringify(
routes,
null,
2
)} as const;\n\nexport default clientRoutes;`
`// AUTO-GENERATED FILE\nconst clientRoutes = ${JSON.stringify(routes, null, 2)} as const;\n\nexport default clientRoutes;`
);
console.log(`📄 clientRoutes.ts generated at ${outPath}`);
console.log(`📄 clientRoutes.ts saved → ${outPath}`);
}

View File

@@ -8,11 +8,14 @@
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@types/babel__traverse": "^7.28.0",
"@types/lodash": "^4.17.20",
"@types/minimist": "^1.2.5",
"dedent": "^1.7.0",
"dotenv": "^17.2.1",
"lodash": "^4.17.21",
"minimist": "^1.2.8",
"ora": "^9.0.0",
"yaml": "^2.8.1",
},
"peerDependencies": {
"typescript": "^5",
@@ -48,6 +51,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="],
"@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@@ -74,6 +79,8 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -100,6 +107,8 @@
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "g3n",
"version": "1.0.14",
"version": "1.0.30",
"type": "module",
"bin": {
"g3n": "./bin/g3n.ts"
@@ -13,10 +13,13 @@
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@types/babel__traverse": "^7.28.0",
"@types/lodash": "^4.17.20",
"@types/minimist": "^1.2.5",
"dedent": "^1.7.0",
"dotenv": "^17.2.1",
"lodash": "^4.17.21",
"minimist": "^1.2.8",
"ora": "^9.0.0"
"ora": "^9.0.0",
"yaml": "^2.8.1"
}
}