This commit is contained in:
bipproduction
2025-12-06 19:39:33 +08:00
commit f07b60b310
24 changed files with 11196 additions and 0 deletions

87
bin/build.ts Executable file
View File

@@ -0,0 +1,87 @@
import { execSync } from "node:child_process";
import { readdir, rm, mkdir, cp } from "node:fs/promises";
import { join, relative, dirname } from "node:path";
const SRC = "src";
const DIST = "dist";
const ASSET_EXT = [
".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif",
".json", ".html", ".css", ".txt", ".ico", ".md"
];
// Recursively scan directory tree
async function walk(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await walk(full));
} else {
files.push(full);
}
}
return files;
}
async function build() {
console.log("🧹 Cleaning dist/...");
await rm(DIST, { recursive: true, force: true });
await mkdir(DIST, { recursive: true });
console.log("🔍 Scanning src/...");
const allFiles = await walk(SRC);
const tsFiles = allFiles.filter(f =>
f.endsWith(".ts") || f.endsWith(".tsx") || f.endsWith(".js")
);
const assets = allFiles.filter(f =>
ASSET_EXT.some(ext => f.toLowerCase().endsWith(ext))
);
console.log("⚡ Building & Minifying TypeScript...");
for (const file of tsFiles) {
const rel = relative(SRC, file);
const outDir = join(DIST, dirname(rel));
await mkdir(outDir, { recursive: true });
await Bun.build({
entrypoints: [file],
outdir: outDir,
splitting: false,
minify: true, // ← minify otomatis
sourcemap: "external",
target: "browser"
});
console.log(" ✔ Built:", rel);
}
console.log("📁 Copying assets...");
for (const file of assets) {
const rel = relative(SRC, file);
const dest = join(DIST, rel);
await mkdir(dirname(dest), { recursive: true });
await cp(file, dest);
console.log(" ✔ Copied:", rel);
}
console.log("🎉 Build complete!");
}
const version = execSync('npm view lodash version').toString().trim();
console.log("🚀 Version:", version);
execSync("cd src && npm version ", { stdio: 'inherit' })
build()

408
bin/generate.ts Executable file
View File

@@ -0,0 +1,408 @@
import fs from 'fs/promises';
import path from 'path';
import _ from 'lodash';
import { execSync } from 'child_process';
const NAMESPACE = process.env.NAMESPACE
const OPENAPI_URL = process.env.OPENAPI_URL
if (!NAMESPACE || !OPENAPI_URL) {
throw new Error('NAMESPACE and OPENAPI_URL are required')
}
const namespaceCase = _.startCase(_.camelCase(NAMESPACE)).replace(/ /g, '');
const OUT_DIR = path.join('src', 'nodes');
const OUT_FILE = path.join(OUT_DIR, `${namespaceCase}.node.ts`);
const CREDENTIAL_NAME = _.camelCase(NAMESPACE);
interface OpenAPI {
paths: Record<string, any>;
components?: any;
tags?: { name: string }[];
}
// helpers
const safe = (s: string) => s.replace(/[^a-zA-Z0-9]/g, '_');
// load OpenAPI
async function loadOpenAPI(): Promise<OpenAPI> {
const url = OPENAPI_URL!
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch OpenAPI: ${res.status} ${res.statusText}`);
return res.json() as Promise<OpenAPI>;
}
// convert operation to value
function operationValue(tag: string, operationId: string) {
return _.snakeCase(`${tag}_${operationId}`);
}
// build properties for dropdown + dynamic inputs
function buildPropertiesBlock(ops: Array<any>) {
const options = ops.map((op) => {
const value = operationValue(op.tag, op.operationId);
const label = `${op.tag} ${_.kebabCase(op.operationId).replace(/-/g, ' ')}`;
return `{ name: '${label}', value: '${value}', description: ${JSON.stringify(
op.summary || op.description || '',
)}, action: '${label}' }`;
});
const dropdown = `
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
${options.join(',\n ')}
],
default: '${operationValue(ops[0].tag, ops[0].operationId).replace(/_/g, ' ')}',
description: 'Pilih endpoint yang akan dipanggil'
}
`;
const dynamicProps: string[] = [];
for (const op of ops) {
const value = operationValue(op.tag, op.operationId);
// Query fields
for (const name of op.query ?? []) {
dynamicProps.push(`
{
displayName: 'Query ${name}',
name: 'query_${name}',
type: 'string',
default: '',
placeholder: '${name}',
description: '${name}',
displayOptions: { show: { operation: ['${value}'] , "@tool": [true]} }
}`);
}
// Body fields (required only)
const bodyRequired = op.body?.required ?? [];
const bodySchema = op.body?.schema ?? {};
for (const name of bodyRequired) {
const schema = bodySchema[name] ?? {};
let type = 'string';
if (schema.type === 'number' || schema.type === 'integer') type = 'number';
if (schema.type === 'boolean') type = 'boolean';
const defVal =
type === 'string' ? "''" : type === 'number' ? '0' : type === 'boolean' ? 'false' : "''";
dynamicProps.push(`
{
displayName: 'Body ${name}',
name: 'body_${name}',
type: '${type}',
default: ${defVal},
placeholder: '${name}',
description: '${schema?.description ?? name}',
displayOptions: { show: { operation: ['${value}'], "@tool": [true] } }
}`);
}
}
return `[
${dropdown},
${dynamicProps.join(',\n ')}
]`;
}
// build execute switch
function buildExecuteSwitch(ops: Array<any>) {
const cases: string[] = [];
for (const op of ops) {
const val = operationValue(op.tag, op.operationId);
const method = (op.method || 'get').toLowerCase();
const url = op.path;
const q = op.query ?? [];
const bodyReq = op.body?.required ?? [];
const qLines =
q
.map(
(name: string) =>
`const query_${_.snakeCase(name)} = this.getNodeParameter('query_${_.snakeCase(name)}', i, '') as string;`,
)
.join('\n ') || '';
const bodyLines =
bodyReq
.map(
(name: string) =>
`const body_${_.snakeCase(name)} = this.getNodeParameter('body_${_.snakeCase(name)}', i, '') as any;`,
)
.join('\n ') || '';
const bodyObject =
bodyReq.length > 0
? `const body = { ${bodyReq.map((n: string) => `${_.snakeCase(n)}: body_${_.snakeCase(n)}`).join(', ')} };`
: 'const body = undefined;';
const paramsObj =
q.length > 0 ? `params: { ${q.map((n: string) => `${_.snakeCase(n)}: query_${_.snakeCase(n)}`).join(', ')} },` : '';
const dataLine = method === 'get' ? '' : 'data: body,';
cases.push(`
case '${val}': {
${qLines}
${bodyLines}
${bodyObject}
url = baseUrl + '${url}';
method = '${method}';
axiosConfig = {
headers: finalHeaders,
${paramsObj}
${dataLine}
};
break;
}
`);
}
return `
switch (operation) {
${cases.join('\n')}
default:
throw new Error('Unknown operation: ' + operation);
}
`;
}
function credentialsText() {
const text = `
import { ICredentialType, INodeProperties } from "n8n-workflow";
export class ${namespaceCase}Credentials implements ICredentialType {
name = "${CREDENTIAL_NAME}";
displayName = "${CREDENTIAL_NAME} (Bearer Token)";
properties: INodeProperties[] = [
{
displayName: "Base URL",
name: "baseUrl",
type: "string",
default: "",
placeholder: "https://api.example.com",
description: "Masukkan URL dasar API tanpa garis miring di akhir",
required: true,
},
{
displayName: "Bearer Token",
name: "token",
type: "string",
default: "",
typeOptions: { password: true },
description: "Masukkan token autentikasi Bearer (tanpa 'Bearer ' di depannya)",
required: true,
},
];
}
`
return text
}
// top-level
function generateNodeFile(ops: Array<any>) {
const propertiesBlock = buildPropertiesBlock(ops);
const executeSwitch = buildExecuteSwitch(ops);
return `import type { INodeType, INodeTypeDescription, IExecuteFunctions } from 'n8n-workflow';
import axios from 'axios';
export class ${namespaceCase} implements INodeType {
description: INodeTypeDescription = {
displayName: '${NAMESPACE}',
name: '${namespaceCase}',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"]}}',
description: 'Universal node generated from OpenAPI - satu node memuat semua endpoint',
defaults: { name: '${namespaceCase}' },
inputs: ['main'],
outputs: ['main'],
credentials: [
{ name: '${CREDENTIAL_NAME}', required: true }
],
properties: ${propertiesBlock}
};
async execute(this: IExecuteFunctions) {
const items = this.getInputData();
const returnData: any[] = [];
const creds = await this.getCredentials('${CREDENTIAL_NAME}') as any;
const baseUrlRaw = creds?.baseUrl ?? '';
const apiKeyRaw = creds?.token ?? '';
const baseUrl = String(baseUrlRaw || '').replace(/\\/$/, '');
const apiKey = String(apiKeyRaw || '').trim().replace(/^Bearer\\s+/i, '');
if (!baseUrl) throw new Error('Base URL tidak ditemukan');
if (!apiKey) throw new Error('Token tidak ditemukan');
for (let i = 0; i < items.length; i++) {
const operation = this.getNodeParameter('operation', i) as string;
let url = '';
let method: any = 'get';
let axiosConfig: any = {};
const finalHeaders: any = { Authorization: \`Bearer \${apiKey}\` };
${executeSwitch}
try {
const response = await axios({ method, url, ...axiosConfig });
returnData.push(response.data);
} catch (err: any) {
returnData.push({
error: true,
message: err.message,
status: err.response?.status,
data: err.response?.data,
});
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}
`;
}
function iconText(text: string) {
return `
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" aria-label="Icon AB square">
<defs>
<style>
.letters {
fill: #3b82f6; /* biru */
font-family: "Inter", "Segoe UI", Roboto, sans-serif;
font-weight: 800;
font-size: 56px;
}
</style>
</defs>
<text class="letters" x="64" y="78" text-anchor="middle" dominant-baseline="middle">${text}</text>
</svg>
`
}
function packageText({ name, className }: { name: string, className: string }) {
return `
{
"name": "n8n-nodes-${name}",
"version": "1.0.43",
"keywords": [
"n8n",
"n8n-nodes"
],
"author": {
"name": "makuro",
"phone": "6289697338821"
},
"license": "ISC",
"description": "",
"n8n": {
"nodes": [
"nodes/${className}.node.js"
],
"n8nNodesApiVersion": 1,
"credentials": [
"credentials/${className}Credentials.credentials.js"
]
}
}`
}
async function run() {
await fs.rm('src', { recursive: true }).catch(() => { })
await fs.mkdir('src/credentials', { recursive: true })
await fs.mkdir('src/nodes', { recursive: true })
console.log('💡 Loading OpenAPI...');
const api = await loadOpenAPI();
const ops: Array<any> = [];
for (const pathStr of Object.keys(api.paths || {})) {
const pathObj = api.paths[pathStr];
for (const method of Object.keys(pathObj)) {
const operation = pathObj[method];
const tags = operation.tags?.length ? operation.tags : ['default'];
console.log("✅", _.upperCase(method).padEnd(7), pathStr);
const operationId = operation.operationId || `${method}_${safe(pathStr)}`;
const query = (operation.parameters ?? [])
.filter((p: any) => p.in === 'query')
.map((p: any) => p.name);
const requestBody =
operation.requestBody?.content?.['application/json']?.schema ??
operation.requestBody?.content?.['multipart/form-data']?.schema ??
null;
const bodyRequired = requestBody?.required ?? [];
const bodyProps = requestBody?.properties ?? {};
for (const tag of tags) {
ops.push({
tag,
path: pathStr,
method,
operationId,
summary: operation.summary || '',
description: operation.description || '',
query,
body: {
required: bodyRequired,
schema: bodyProps,
},
});
}
}
}
if (ops.length === 0) throw new Error('No operations found');
const raw = generateNodeFile(ops);
console.log('[GEN] Generated single node file:', OUT_FILE);
await fs.writeFile(OUT_FILE, raw, 'utf-8');
const credentialsRaw = credentialsText();
await fs.writeFile(`src/credentials/${namespaceCase}Credentials.credentials.ts`, credentialsRaw, 'utf-8')
const iconRaw = iconText(_.upperCase(namespaceCase.substring(0, 3)));
await fs.writeFile(`src/nodes/icon.svg`, iconRaw, 'utf-8')
const packageRaw = packageText({ name: NAMESPACE!, className: namespaceCase });
await fs.writeFile(`src/package.json`, packageRaw, 'utf-8')
execSync('npx prettier --write src')
}
run()
.catch((err) => {
console.error(err);
process.exit(1);
})
.then(() => {
console.log('✅ Generated node file:', OUT_FILE);
process.exit(0);
})

61
bin/init.ts Executable file
View File

@@ -0,0 +1,61 @@
import fs from 'fs/promises';
import { execSync } from 'child_process'
async function init() {
console.log("[INIT] Initializing...")
try {
await fs.access(".env")
} catch (error) {
let envText = ""
envText += "NAMESPACE=\n"
envText += "OPENAPI_URL=\n"
// generate .env
await fs.writeFile(".env", envText)
console.log('[GEN] Generated .env');
}
const NAMESPACE = process.env.NAMESPACE
const OPENAPI_URL = process.env.OPENAPI_URL
if (!NAMESPACE || !OPENAPI_URL) {
throw new Error('NAMESPACE and OPENAPI_URL are required')
}
await fs.rmdir("src", { recursive: true }).catch(() => { })
try {
await fs.access(".git")
console.log("[INIT] Git already initialized")
execSync("rm -rf .git")
execSync("git init")
} catch (error) {
execSync("git init")
}
try {
console.log("[INIT] Gitignored...")
await fs.access(".gitignore")
} catch (e) {
execSync("npx -y gitignore node")
console.log('[INIT] gitignored');
}
try {
console.log("[INIT] Npmignored...")
await fs.access(".npmignore")
} catch (e) {
execSync("npx -y npmignore node")
console.log('[INIT] npmignored');
}
console.log('[INIT] Installing dependencies...');
execSync("bun install", { stdio: 'inherit' })
}
init()

3
bin/publish.ts Normal file
View File

@@ -0,0 +1,3 @@
import { execSync } from "child_process";
execSync("cd dist && npm publish", { stdio: 'inherit' })

32
bin/version_update.ts Normal file
View File

@@ -0,0 +1,32 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
const NAMESPACE = process.env.NAMESPACE
if (!NAMESPACE) {
throw new Error('NAMESPACE is required')
}
// 1. Ambil versi remote dari npm
const remoteVersion = execSync(`npm view n8n-nodes-${NAMESPACE} version`)
.toString()
.trim();
console.log("🔍 Remote version:", remoteVersion);
// 2. Pecah versi → major.minor.patch
const [major, minor, patch] = remoteVersion.split(".").map(Number);
// 3. Generate versi baru: remote + 1 patch
const newLocalVersion = `${major}.${minor}.${patch + 1}`;
const pkgPath = "src/package.json";
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
const pkgPathDist = "dist/package.json";
const pkgDist = JSON.parse(fs.readFileSync(pkgPathDist, "utf8"));
pkg.version = newLocalVersion;
pkgDist.version = newLocalVersion;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
fs.writeFileSync(pkgPathDist, JSON.stringify(pkgDist, null, 2));