This commit is contained in:
bipproduction
2025-11-23 17:06:17 +08:00
parent 9dcc8209ec
commit 12bab64849
6 changed files with 330 additions and 102 deletions

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
@@ -103,6 +106,9 @@ async function main(): Promise<void> {
case "compose":
handleCompose(name);
break;
case "compose-log":
applyLogRotateCompose("compose.yml");
break;
case "docker-file":
generateDockerfile();
@@ -175,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;
}

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,30 +74,41 @@ 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: string) => {
return `
const generate = (name: string, env: "stg" | "prod", port: string) => {
return `
#!/bin/bash
echo "Generating directory..."
@@ -91,9 +120,6 @@ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDfXPd7ab21qdKtKKdv2bLxIa9hEqq2oLLj7c3i/rN2f
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: string) {
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

@@ -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.20",
"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"
}
}