Compare commits
10 Commits
371feb4f05
...
12bab64849
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12bab64849 | ||
|
|
9dcc8209ec | ||
|
|
3f4127e3d8 | ||
|
|
1f71d34d97 | ||
|
|
5ef8241989 | ||
|
|
3874308cce | ||
|
|
21e28497af | ||
|
|
b3b8d9c73e | ||
|
|
992b0ca2db | ||
|
|
cb789e43b7 |
37
README.md
37
README.md
@@ -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.
|
||||
21
bin/g3n.ts
21
bin/g3n.ts
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
67
bin/src/compose-log.ts
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
221
bin/src/route.ts
221
bin/src/route.ts
@@ -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}`);
|
||||
}
|
||||
|
||||
9
bun.lock
9
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user