Skip to content

Commit a19cf35

Browse files
authored
Merge pull request #131 from devforth/create-plugin
feat: add create-plugin command to AdminForth CLI
2 parents 796e474 + ed4babd commit a19cf35

File tree

11 files changed

+404
-107
lines changed

11 files changed

+404
-107
lines changed

adminforth/commands/cli.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,22 @@ const command = args[0];
66
import bundle from "./bundle.js";
77
import createApp from "./createApp/main.js";
88
import generateModels from "./generateModels.js";
9-
9+
import createPlugin from "./createPlugin/main.js";
1010
switch (command) {
1111
case "create-app":
1212
createApp(args);
1313
break;
14+
case "create-plugin":
15+
createPlugin(args);
16+
break;
1417
case "generate-models":
1518
generateModels();
1619
break;
1720
case "bundle":
1821
bundle();
1922
break;
2023
default:
21-
console.log("Unknown command. Available commands: create-app, generate-models, bundle");
22-
}
24+
console.log(
25+
"Unknown command. Available commands: create-app, create-plugin, generate-models, bundle"
26+
);
27+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import chalk from "chalk";
2+
3+
import {
4+
parseArgumentsIntoOptions,
5+
prepareWorkflow,
6+
promptForMissingOptions,
7+
} from "./utils.js";
8+
9+
export default async function createPlugin(args) {
10+
// Step 1: Parse CLI arguments with `arg`
11+
let options = parseArgumentsIntoOptions(args);
12+
13+
// Step 2: Ask for missing arguments via `inquirer`
14+
options = await promptForMissingOptions(options);
15+
16+
// Step 3: Prepare a Listr-based workflow
17+
const tasks = prepareWorkflow(options);
18+
19+
// Step 4: Run tasks
20+
try {
21+
await tasks.run();
22+
} catch (err) {
23+
console.error(chalk.red(`\n❌ ${err.message}\n`));
24+
process.exit(1);
25+
}
26+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
custom/node_modules
3+
dist
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"compilerOptions": {
3+
"baseUrl": ".", // This should point to your project root
4+
"paths": {
5+
"@/*": [
6+
// "node_modules/adminforth/dist/spa/src/*"
7+
"../../../spa/src/*"
8+
],
9+
"*": [
10+
// "node_modules/adminforth/dist/spa/node_modules/*"
11+
"../../../spa/node_modules/*"
12+
],
13+
"@@/*": [
14+
// "node_modules/adminforth/dist/spa/src/*"
15+
"."
16+
]
17+
}
18+
}
19+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { AdminForthPlugin } from "adminforth";
2+
import type { IAdminForth, IHttpServer, AdminForthResourcePages, AdminForthResourceColumn, AdminForthDataTypes, AdminForthResource } from "adminforth";
3+
import type { PluginOptions } from './types.js';
4+
5+
6+
export default class ChatGptPlugin extends AdminForthPlugin {
7+
options: PluginOptions;
8+
9+
constructor(options: PluginOptions) {
10+
super(options, import.meta.url);
11+
this.options = options;
12+
}
13+
14+
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
15+
super.modifyResourceConfig(adminforth, resourceConfig);
16+
17+
// simply modify resourceConfig or adminforth.config. You can get access to plugin options via this.options;
18+
}
19+
20+
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
21+
// optional method where you can safely check field types after database discovery was performed
22+
}
23+
24+
instanceUniqueRepresentation(pluginOptions: any) : string {
25+
// optional method to return unique string representation of plugin instance.
26+
// Needed if plugin can have multiple instances on one resource
27+
return `single`;
28+
}
29+
30+
setupEndpoints(server: IHttpServer) {
31+
server.endpoint({
32+
method: 'POST',
33+
path: `/plugin/${this.pluginInstanceId}/example`,
34+
handler: async ({ body }) => {
35+
const { name } = body;
36+
return { hey: `Hello ${name}` };
37+
}
38+
});
39+
}
40+
41+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "{{pluginName}}",
3+
"version": "1.0.1",
4+
"main": "dist/index.js",
5+
"types": "dist/index.d.ts",
6+
"type": "module",
7+
"scripts": {
8+
"build": "tsc && rsync -av --exclude 'node_modules' custom dist/ && npm version patch"
9+
},
10+
"keywords": [],
11+
"author": "",
12+
"license": "ISC",
13+
"description": "",
14+
"devDependencies": {
15+
"@types/node": "latest",
16+
"typescript": "^5.7.3"
17+
},
18+
"dependencies": {
19+
"adminforth": "latest",
20+
}
21+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"compilerOptions": {
3+
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include*/
4+
"module": "node16", /* Specify what module code is generated. */
5+
"outDir": "./dist", /* Specify an output folder for all emitted files. */
6+
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. */
7+
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
8+
"strict": false, /* Enable all strict type-checking options. */
9+
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
10+
},
11+
"exclude": ["node_modules", "dist", "custom"], /* Exclude files from compilation. */
12+
}
13+
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface PluginOptions {
2+
3+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import arg from 'arg';
2+
import chalk from 'chalk';
3+
import fs from 'fs';
4+
import fse from 'fs-extra';
5+
import inquirer from 'inquirer';
6+
import path from 'path';
7+
import { Listr } from 'listr2';
8+
import { fileURLToPath } from 'url';
9+
import { execa } from 'execa';
10+
import Handlebars from 'handlebars';
11+
12+
export function parseArgumentsIntoOptions(rawArgs) {
13+
const args = arg(
14+
{
15+
"--plugin-name": String,
16+
// you can add more flags here if needed
17+
},
18+
{
19+
argv: rawArgs.slice(1), // skip "create-plugin"
20+
}
21+
);
22+
23+
return {
24+
pluginName: args["--plugin-name"],
25+
};
26+
}
27+
28+
export async function promptForMissingOptions(options) {
29+
const questions = [];
30+
31+
if (!options.pluginName) {
32+
questions.push({
33+
type: "input",
34+
name: "pluginName",
35+
message: "Please specify the name of the plugin >",
36+
default: "adminforth-plugin",
37+
});
38+
}
39+
40+
const answers = await inquirer.prompt(questions);
41+
return {
42+
...options,
43+
pluginName: options.pluginName || answers.pluginName,
44+
};
45+
}
46+
47+
function checkNodeVersion(minRequiredVersion = 20) {
48+
const current = process.versions.node.split(".");
49+
const major = parseInt(current[0], 10);
50+
51+
if (isNaN(major) || major < minRequiredVersion) {
52+
throw new Error(
53+
`Node.js v${minRequiredVersion}+ is required. You have ${process.versions.node}. ` +
54+
`Please upgrade Node.js. We recommend using nvm for managing multiple Node.js versions.`
55+
);
56+
}
57+
}
58+
59+
function checkForExistingPackageJson() {
60+
if (fs.existsSync(path.join(process.cwd(), "package.json"))) {
61+
throw new Error(
62+
`A package.json already exists in this directory.\n` +
63+
`Please remove it or use an empty directory.`
64+
);
65+
}
66+
}
67+
68+
function initialChecks() {
69+
return [
70+
{
71+
title: "👀 Checking Node.js version...",
72+
task: () => checkNodeVersion(20),
73+
},
74+
{
75+
title: "👀 Validating current working directory...",
76+
task: () => checkForExistingPackageJson(),
77+
},
78+
];
79+
}
80+
81+
function renderHBSTemplate(templatePath, data) {
82+
const template = fs.readFileSync(templatePath, "utf-8");
83+
const compiled = Handlebars.compile(template);
84+
return compiled(data);
85+
}
86+
87+
async function scaffoldProject(ctx, options, cwd) {
88+
const pluginName = options.pluginName;
89+
90+
const filename = fileURLToPath(import.meta.url);
91+
const dirname = path.dirname(filename);
92+
93+
// Prepare directories
94+
ctx.customDir = path.join(cwd, "custom");
95+
await fse.ensureDir(ctx.customDir);
96+
97+
// Write templated files
98+
await writeTemplateFiles(dirname, cwd, {
99+
pluginName,
100+
});
101+
}
102+
103+
async function writeTemplateFiles(dirname, cwd, options) {
104+
const { pluginName } = options;
105+
106+
// Build a list of files to generate
107+
const templateTasks = [
108+
{
109+
src: "tsconfig.json.hbs",
110+
dest: "tsconfig.json",
111+
data: {},
112+
},
113+
{
114+
src: "package.json.hbs",
115+
dest: "package.json",
116+
data: { pluginName },
117+
},
118+
{
119+
src: "index.ts.hbs",
120+
dest: "index.ts",
121+
data: {},
122+
},
123+
{
124+
src: ".gitignore.hbs",
125+
dest: ".gitignore",
126+
data: {},
127+
},
128+
{
129+
src: "types.ts.hbs",
130+
dest: "types.ts",
131+
data: {},
132+
},
133+
{
134+
src: "custom/tsconfig.json.hbs",
135+
dest: "custom/tsconfig.json",
136+
data: {},
137+
},
138+
];
139+
140+
for (const task of templateTasks) {
141+
// If a condition is specified and false, skip this file
142+
if (task.condition === false) continue;
143+
144+
const destPath = path.join(cwd, task.dest);
145+
fse.ensureDirSync(path.dirname(destPath));
146+
147+
if (task.empty) {
148+
fs.writeFileSync(destPath, "");
149+
} else {
150+
const templatePath = path.join(dirname, "templates", task.src);
151+
const compiled = renderHBSTemplate(templatePath, task.data);
152+
fs.writeFileSync(destPath, compiled);
153+
}
154+
}
155+
}
156+
157+
async function installDependencies(ctx, cwd) {
158+
const customDir = ctx.customDir;
159+
160+
await Promise.all([
161+
await execa("npm", ["install", "--no-package-lock"], { cwd }),
162+
await execa("npm", ["install"], { cwd: customDir }),
163+
]);
164+
}
165+
166+
function generateFinalInstructions() {
167+
let instruction = "⏭️ Your plugin is ready! Next steps:\n";
168+
169+
instruction += `
170+
${chalk.dim("// Build your plugin")}
171+
${chalk.cyan("$ npm run build")}\n`;
172+
173+
instruction += `
174+
${chalk.dim("// To test your plugin locally")}
175+
${chalk.cyan("$ npm link")}\n`;
176+
177+
instruction += `
178+
${chalk.dim("// In your AdminForth project")}
179+
${chalk.cyan("$ npm link " + chalk.italic("your-plugin-name"))}\n`;
180+
181+
instruction += "\n😉 Happy coding!";
182+
183+
return instruction;
184+
}
185+
186+
export function prepareWorkflow(options) {
187+
const cwd = process.cwd();
188+
const tasks = new Listr(
189+
[
190+
{
191+
title: "🔍 Initial checks...",
192+
task: (_, task) => task.newListr(initialChecks(), { concurrent: true }),
193+
},
194+
{
195+
title: "🚀 Scaffolding your plugin...",
196+
task: async (ctx) => scaffoldProject(ctx, options, cwd),
197+
},
198+
{
199+
title: "📦 Installing dependencies...",
200+
task: async (ctx) => installDependencies(ctx, cwd),
201+
},
202+
{
203+
title: "📝 Preparing final instructions...",
204+
task: (ctx) => {
205+
console.log(
206+
chalk.green(`✅ Successfully created your new AdminForth plugin!\n`)
207+
);
208+
console.log(generateFinalInstructions());
209+
console.log("\n\n");
210+
},
211+
},
212+
],
213+
{
214+
rendererOptions: { collapseSubtasks: false },
215+
concurrent: false,
216+
exitOnError: true,
217+
collectErrors: true,
218+
}
219+
);
220+
221+
return tasks;
222+
}

0 commit comments

Comments
 (0)