@cosider.construction/eapp-maker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,741 @@
1
+ #!/usr/bin/env node
2
+ import minimist from 'minimist';
3
+ import chalk from 'chalk';
4
+ import path from 'path';
5
+ import fs from 'fs-extra';
6
+ import { fileURLToPath } from 'url';
7
+ import { input, select, confirm, password } from '@inquirer/prompts';
8
+
9
+ const __myDir = path.dirname(fileURLToPath(import.meta.url));
10
+ const stubsBase = path.join(__myDir, 'stubs');
11
+
12
+ const HELP = `
13
+ ${chalk.bold.cyan('eapp-maker')} — Scaffold a new Electron + Vue ERP project
14
+
15
+ ${chalk.bold('Usage:')}
16
+ npx eapp-maker <app-name> [options]
17
+ npm create @cosider.construction/eapp-maker <app-name>
18
+
19
+ ${chalk.bold('Options:')}
20
+ --tailwind Include Tailwind CSS
21
+ --db <type> sqlite | mssql | mysql | postgres
22
+ --db-host <host> DB server host
23
+ --db-port <port> DB server port
24
+ --db-user <user> DB username
25
+ --db-pass <pass> DB password
26
+ --db-name <name> Database name
27
+ --db-mode <mode> single | multi
28
+ --modules <list> Comma-separated modules e.g. rh,stk,trv
29
+ --help, -h Show this help
30
+ `;
31
+
32
+ // ─── File writer ──────────────────────────────────────────────────────────────
33
+
34
+ async function write(filePath, content) {
35
+ await fs.ensureDir(path.dirname(filePath));
36
+ await fs.writeFile(filePath, content, 'utf8');
37
+ console.log(chalk.green(' created'), chalk.dim(path.relative(process.cwd(), filePath)));
38
+ }
39
+
40
+ async function writeJson(filePath, data) {
41
+ await write(filePath, JSON.stringify(data, null, 2) + '\n');
42
+ }
43
+
44
+ // ─── Templates ────────────────────────────────────────────────────────────────
45
+
46
+ const engineLoader = () => `import { ipcMain } from 'electron';
47
+ import fs from 'fs';
48
+ import path from 'path';
49
+ import { fileURLToPath } from 'url';
50
+ import { checkPermission } from './auth.js';
51
+
52
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
53
+ const BACKEND_ROOT = path.resolve(__dirname, '..');
54
+
55
+ export async function loadHandlers() {
56
+ const dirs = [
57
+ path.join(BACKEND_ROOT, 'shared'),
58
+ path.join(BACKEND_ROOT, 'modules'),
59
+ ];
60
+ for (const dir of dirs) {
61
+ if (!fs.existsSync(dir)) continue;
62
+ for (const ipcPath of walkIpc(dir)) registerIpc(ipcPath);
63
+ }
64
+ }
65
+
66
+ function walkIpc(root, out = []) {
67
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
68
+ const full = path.join(root, e.name);
69
+ if (e.isDirectory()) walkIpc(full, out);
70
+ else if (e.name === 'ipc.json') out.push(full);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ async function registerIpc(ipcJsonPath) {
76
+ let config;
77
+ try { config = JSON.parse(fs.readFileSync(ipcJsonPath, 'utf8')); }
78
+ catch (e) { console.warn('[engine] bad ipc.json:', ipcJsonPath); return; }
79
+
80
+ const { route, schema, methods = {} } = config;
81
+ const entityDir = path.dirname(ipcJsonPath);
82
+ let ctrl;
83
+ try { ctrl = await import(path.join(entityDir, 'controller.js')); }
84
+ catch (e) { console.warn('[engine] no controller at', entityDir); return; }
85
+
86
+ for (const [methodName, def] of Object.entries(methods)) {
87
+ const handler = typeof def === 'string' ? def : def.handler;
88
+ const permission = typeof def === 'object' ? def.permission : null;
89
+ const channel = \`\${route}:\${methodName}\`;
90
+
91
+ if (typeof ctrl[handler] !== 'function') {
92
+ console.warn(\`[engine] \${handler} not found in \${entityDir}\`);
93
+ continue;
94
+ }
95
+
96
+ ipcMain.handle(channel, async (_, payload) => {
97
+ if (permission) {
98
+ const ok = await checkPermission(payload?.__userId, permission);
99
+ if (!ok) return { error: 'FORBIDDEN', message: \`Missing: \${permission}\` };
100
+ }
101
+ try {
102
+ const { id, data } = payload || {};
103
+ if (methodName === 'getById') return await ctrl[handler](id);
104
+ if (methodName === 'update') return await ctrl[handler](id, data);
105
+ if (methodName === 'delete') return await ctrl[handler](id);
106
+ if (methodName === 'add') return await ctrl[handler](data);
107
+ return await ctrl[handler](payload);
108
+ } catch (err) {
109
+ return { error: 'INTERNAL_ERROR', message: err.message };
110
+ }
111
+ });
112
+ console.log(\`[engine] \${channel}\`);
113
+ }
114
+ }
115
+ `;
116
+
117
+ const engineAuth = () => `import * as permModel from '../shared/PERMISSION/model.js';
118
+
119
+ export async function checkPermission(userId, permissionString) {
120
+ if (!userId) return false;
121
+ try {
122
+ const p = await permModel.findByUserAndPermission(userId, permissionString);
123
+ return !!p;
124
+ } catch { return false; }
125
+ }
126
+ `;
127
+
128
+ const engineDbSqlite = () => `import Database from 'better-sqlite3';
129
+ import path from 'path';
130
+ import { app } from 'electron';
131
+
132
+ const _db = new Database(path.join(app.getPath('userData'), 'app.db'));
133
+ _db.pragma('journal_mode = WAL');
134
+
135
+ function scope(schema) {
136
+ // SQLite has no schemas — schema param kept for API compatibility
137
+ return {
138
+ query: (sql, p = {}) => _db.prepare(sql).all(p),
139
+ queryOne: (sql, p = {}) => _db.prepare(sql).get(p) ?? null,
140
+ execute: (sql, p = {}) => _db.prepare(sql).run(p),
141
+ };
142
+ }
143
+
144
+ export const dbo = scope('dbo');
145
+ // Add module scopes as modules are generated: export const rh = scope('rh');
146
+ `;
147
+
148
+ const engineDbExternal = ({ dbType, connections, mode }) => {
149
+ const driver = { mssql: `import sql from 'mssql';`, mysql: `import mysql from 'mysql2/promise';`, postgres: `import pg from 'pg';` }[dbType];
150
+ return `${driver}
151
+ import config from '../../db.config.json' assert { type: 'json' };
152
+
153
+ let activeDb = config.connections[0]?.database || '';
154
+ export const setActiveDatabase = (name) => { activeDb = name; };
155
+ export const getActiveDatabase = () => activeDb;
156
+
157
+ function qualify(schema, table) {
158
+ return \`[\${activeDb}].[\${schema}].[\${table}]\`;
159
+ }
160
+
161
+ function scope(schema) {
162
+ async function conn() {
163
+ const c = config.connections.find(x => x.database === activeDb) || config.connections[0];
164
+ ${dbType === 'mssql'
165
+ ? `return sql.connect({ server: c.host, port: c.port, user: c.user, password: c.password, database: c.database, options: { trustServerCertificate: true } });`
166
+ : dbType === 'mysql'
167
+ ? `return mysql.createConnection({ host: c.host, port: c.port, user: c.user, password: c.password, database: c.database });`
168
+ : `const client = new pg.Client({ host: c.host, port: c.port, user: c.user, password: c.password, database: c.database }); await client.connect(); return client;`}
169
+ }
170
+ function q(sql) {
171
+ return sql
172
+ .replace(/FROM (\\w+)/gi, (_, t) => \`FROM \${qualify(schema, t)}\`)
173
+ .replace(/JOIN (\\w+)/gi, (_, t) => \`JOIN \${qualify(schema, t)}\`)
174
+ .replace(/INTO (\\w+)/gi, (_, t) => \`INTO \${qualify(schema, t)}\`)
175
+ .replace(/UPDATE (\\w+)/gi, (_, t) => \`UPDATE \${qualify(schema, t)}\`);
176
+ }
177
+ return {
178
+ async query(sql, params = {}) { const c = await conn(); ${dbType === 'mssql' ? `const r = await c.request().query(q(sql)); return r.recordset;` : `const [rows] = await c.execute(q(sql), Object.values(params)); return rows;`} },
179
+ async queryOne(sql, params = {}) { return (await this.query(sql, params))[0] ?? null; },
180
+ async execute(sql, params = {}) { const c = await conn(); ${dbType === 'mssql' ? `return c.request().query(q(sql));` : `const [r] = await c.execute(q(sql), Object.values(params)); return r;`} },
181
+ };
182
+ }
183
+
184
+ export const dbo = scope('dbo');
185
+ // Add module scopes: export const rh = scope('rh');
186
+ `;
187
+ };
188
+
189
+ const preloadIpc = () => `const { contextBridge, ipcRenderer } = require('electron');
190
+ contextBridge.exposeInMainWorld('api', (channel, data) => ipcRenderer.invoke(channel, data));
191
+ `;
192
+
193
+ const baseApi = () => `export class Api {
194
+ constructor(route) { this.route = route; }
195
+ call(method, data) { return window.api(\`\${this.route}:\${method}\`, data); }
196
+ getAll() { return this.call('getAll'); }
197
+ getById(id) { return this.call('getById', { id }); }
198
+ add(data) { return this.call('add', { data }); }
199
+ update(id, data) { return this.call('update', { id, data }); }
200
+ delete(id) { return this.call('delete', { id }); }
201
+ }
202
+ `;
203
+
204
+ const authStore = () => `import { defineStore } from 'pinia';
205
+ import { ref, computed } from 'vue';
206
+
207
+ export const useAuthStore = defineStore('auth', () => {
208
+ const user = ref(null);
209
+ const permissions = ref([]);
210
+ const isAuthenticated = computed(() => !!user.value);
211
+
212
+ const setSession = (u, p = []) => { user.value = u; permissions.value = p; };
213
+ const clearSession = () => { user.value = null; permissions.value = []; };
214
+ const hasPermission = (p) => permissions.value.includes(p);
215
+ const canAccess = (route) => {
216
+ const req = route?.meta?.permission;
217
+ return req ? hasPermission(req) : isAuthenticated.value;
218
+ };
219
+
220
+ return { user, permissions, isAuthenticated, setSession, clearSession, hasPermission, canAccess };
221
+ });
222
+ `;
223
+
224
+ const router = () => `import { createRouter, createWebHashHistory } from 'vue-router';
225
+ import { useAuthStore } from '@/stores/auth.js';
226
+
227
+ // routes
228
+ const routes = [
229
+ { path: '/', redirect: '/dashboard' },
230
+ { path: '/login', component: () => import('@/views/LoginView.vue') },
231
+ { path: '/access-denied', component: () => import('@/views/AccessDeniedView.vue') },
232
+ ];
233
+
234
+ const router = createRouter({ history: createWebHashHistory(), routes });
235
+
236
+ router.beforeEach((to, _, next) => {
237
+ const auth = useAuthStore();
238
+ if (to.path === '/login') return next();
239
+ if (!auth.isAuthenticated) return next({ path: '/login', query: { redirect: to.fullPath } });
240
+ if (!auth.canAccess(to)) return next('/access-denied');
241
+ next();
242
+ });
243
+
244
+ export default router;
245
+ `;
246
+
247
+ const menuLoader = () => `import { useAuthStore } from '@/stores/auth.js';
248
+ const menus = import.meta.glob('./*.menu.json', { eager: true });
249
+
250
+ export function useMenuLoader() {
251
+ const auth = useAuthStore();
252
+ const hMenu = [];
253
+ const vMenu = [];
254
+ for (const mod of Object.values(menus)) {
255
+ const m = mod.default ?? mod;
256
+ (m.hMenu || []).filter(i => canShow(auth, i)).forEach(i => hMenu.push(i));
257
+ (m.vMenu || []).filter(i => canShow(auth, i)).forEach(i => vMenu.push(i));
258
+ }
259
+ return { hMenu, vMenu };
260
+ }
261
+
262
+ function canShow(auth, item) {
263
+ return !item.permission || auth.hasPermission(item.permission);
264
+ }
265
+ `;
266
+
267
+ const defaultLayout = () => `<script setup>
268
+ import { inject } from 'vue';
269
+ import AppMenuH from '@/components/AppMenuH.vue';
270
+ import AppMenuV from '@/components/AppMenuV.vue';
271
+ import mainMenu from '@/menus/main.menu.json';
272
+ const subHMenu = inject('hMenuConfig', null);
273
+ const hMenuConfig = mainMenu.hMenu || { style: 'classic', items: [] };
274
+ const vMenuConfig = mainMenu.vMenu || { items: [] };
275
+ </script>
276
+ <template>
277
+ <div class="default-layout">
278
+ <AppMenuH :config="hMenuConfig" />
279
+ <AppMenuH v-if="subHMenu" :config="subHMenu" sub />
280
+ <div class="default-layout__body">
281
+ <AppMenuV :config="vMenuConfig" />
282
+ <main class="default-layout__main"><slot /></main>
283
+ </div>
284
+ </div>
285
+ </template>
286
+ <style scoped>
287
+ .default-layout { display:flex;flex-direction:column;height:100vh;overflow:hidden; }
288
+ .default-layout__body { display:flex;flex:1;overflow:hidden; }
289
+ .default-layout__main { flex:1;overflow:auto;padding:1.5rem;background:#1e1e2e; }
290
+ </style>
291
+ `;
292
+
293
+ const loginView = (multiDb) => `<script setup>
294
+ import { ref } from 'vue';
295
+ import { useRouter } from 'vue-router';
296
+ import { useAuthStore } from '@/stores/auth.js';
297
+ ${multiDb ? "import dbConfig from '../../../db.config.json';" : ''}
298
+ const router = useRouter();
299
+ const auth = useAuthStore();
300
+ const username = ref(''); const password = ref(''); const error = ref('');
301
+ ${multiDb ? "const selectedDb = ref(dbConfig.connections[0]?.name || '');\nconst connections = dbConfig.connections;" : ''}
302
+ async function login() {
303
+ error.value = '';
304
+ const result = await window.api('shared.USER:login', { username: username.value, password: password.value${multiDb ? ", database: selectedDb.value" : ''} });
305
+ if (result?.error) { error.value = result.message; return; }
306
+ auth.setSession(result.user, result.permissions);
307
+ router.push('/');
308
+ }
309
+ </script>
310
+ <template>
311
+ <div class="login-page">
312
+ <div class="login-card">
313
+ <h1>Sign In</h1>
314
+ <div v-if="error" class="error">{{ error }}</div>
315
+ <input v-model="username" placeholder="Username" />
316
+ <input v-model="password" placeholder="Password" type="password" />
317
+ ${multiDb ? '<select v-model="selectedDb"><option v-for="c in connections" :key="c.name" :value="c.name">{{ c.name }}</option></select>' : ''}
318
+ <button @click="login">Sign In</button>
319
+ </div>
320
+ </div>
321
+ </template>
322
+ <style scoped>
323
+ .login-page{display:flex;align-items:center;justify-content:center;height:100vh;background:#1e1e2e}
324
+ .login-card{background:#313244;padding:2rem;border-radius:12px;display:flex;flex-direction:column;gap:1rem;min-width:320px}
325
+ h1{color:#cdd6f4;margin:0}input,select{padding:.5rem;border:1px solid #45475a;border-radius:6px;background:#1e1e2e;color:#cdd6f4}
326
+ button{padding:.6rem;background:#89b4fa;color:#1e1e2e;font-weight:bold;border:none;border-radius:6px;cursor:pointer}
327
+ .error{color:#f38ba8;font-size:.875rem}
328
+ </style>
329
+ `;
330
+
331
+ const accessDenied = () => `<template>
332
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:1rem;color:#cdd6f4">
333
+ <h1>Access Denied</h1><p>You don't have permission to view this page.</p>
334
+ <router-link to="/">Go Home</router-link>
335
+ </div>
336
+ </template>`;
337
+
338
+ const sharedModel = (entity) => `import { dbo as db } from '../../../engine/db.js';
339
+ const TABLE = '${entity.toUpperCase()}';
340
+ export async function getAll() { return db.query(\`SELECT * FROM \${TABLE}\`); }
341
+ export async function getById(id) { return db.queryOne(\`SELECT * FROM \${TABLE} WHERE id = @id\`, { id }); }
342
+ export async function add(data) { return db.execute(\`INSERT INTO \${TABLE} DEFAULT VALUES\`, data); }
343
+ export async function update(id, data) { return db.execute(\`UPDATE \${TABLE} SET id=@id WHERE id=@id\`, { ...data, id }); }
344
+ export async function remove(id) { return db.execute(\`DELETE FROM \${TABLE} WHERE id=@id\`, { id }); }
345
+ ${entity === 'USER' ? `
346
+ export async function findByUsername(username) { return db.queryOne(\`SELECT * FROM \${TABLE} WHERE username=@username\`, { username }); }
347
+ export async function findByUserAndPermission(userId, permissionString) {
348
+ return db.queryOne(\`SELECT p.* FROM PERMISSION p JOIN ROLE r ON r.id=p.role_id JOIN USER u ON u.role_id=r.id WHERE u.id=@userId AND p.permission_string=@permissionString\`, { userId, permissionString });
349
+ }` : ''}
350
+ ${entity === 'PERMISSION' ? `
351
+ export async function findByUserAndPermission(userId, permissionString) {
352
+ return db.queryOne(\`SELECT p.* FROM PERMISSION p JOIN ROLE r ON r.id=p.role_id JOIN USER u ON u.role_id=r.id WHERE u.id=@userId AND p.permission_string=@permissionString\`, { userId, permissionString });
353
+ }` : ''}
354
+ `;
355
+
356
+ const sharedController = (entity) => `import * as model from './model.js';
357
+ export const getAll = model.getAll;
358
+ export const getById = model.getById;
359
+ export const add = model.add;
360
+ export const update = model.update;
361
+ export const remove = model.remove;
362
+ ${entity === 'USER' ? `
363
+ export async function login({ username, password, database }) {
364
+ const user = await model.findByUsername(username);
365
+ if (!user) return { error: 'UNAUTHORIZED', message: 'Invalid credentials' };
366
+ // TODO: bcrypt.compare(password, user.password_hash)
367
+ const allPerms = await import('../PERMISSION/model.js').then(m => m.getAll());
368
+ const permissions = allPerms.filter(p => p.role_id === user.role_id).map(p => p.permission_string);
369
+ return { user: { id: user.id, username: user.username }, permissions };
370
+ }` : ''}
371
+ `;
372
+
373
+ const sharedIpc = (entity) => JSON.stringify({
374
+ route: `shared.${entity.toUpperCase()}`,
375
+ schema: 'dbo',
376
+ methods: {
377
+ getAll: { handler: 'getAll', permission: `shared.${entity.toLowerCase()}.view` },
378
+ getById: { handler: 'getById', permission: `shared.${entity.toLowerCase()}.view` },
379
+ add: { handler: 'add', permission: `shared.${entity.toLowerCase()}.add` },
380
+ update: { handler: 'update', permission: `shared.${entity.toLowerCase()}.update` },
381
+ delete: { handler: 'remove', permission: `shared.${entity.toLowerCase()}.delete` },
382
+ ...(entity === 'USER' ? { login: { handler: 'login' } } : {}),
383
+ },
384
+ }, null, 2);
385
+
386
+ const mainJs = () => `import { app, BrowserWindow } from 'electron';
387
+ import { loadIpc } from './loaders/loader.ipc.js';
388
+ import path from 'path';
389
+ import { fileURLToPath } from 'url';
390
+ import fs from 'fs';
391
+
392
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
393
+
394
+ // Runtime config — reads app.config.json next to exe, falls back to db.config.json
395
+ function loadConfig() {
396
+ const exeDir = path.dirname(process.execPath);
397
+ const runtimeCfg = path.join(exeDir, 'app.config.json');
398
+ const devCfg = path.resolve(__dirname, '../../db.config.json');
399
+ try {
400
+ if (fs.existsSync(runtimeCfg)) {
401
+ console.log('[config] loaded runtime config:', runtimeCfg);
402
+ return JSON.parse(fs.readFileSync(runtimeCfg, 'utf8'));
403
+ }
404
+ } catch {}
405
+ console.log('[config] loaded dev config:', devCfg);
406
+ return JSON.parse(fs.readFileSync(devCfg, 'utf8'));
407
+ }
408
+
409
+ global.__appConfig = loadConfig();
410
+
411
+ app.whenReady().then(async () => {
412
+ await loadIpc();
413
+ const win = new BrowserWindow({
414
+ width: 1280, height: 800,
415
+ webPreferences: {
416
+ preload: path.join(__dirname, 'preload.ipc.js'),
417
+ contextIsolation: true,
418
+ nodeIntegration: false,
419
+ },
420
+ });
421
+ process.env.NODE_ENV === 'development'
422
+ ? win.loadURL(process.env.VITE_DEV_SERVER_URL)
423
+ : win.loadFile(path.join(__dirname, '../../dist/renderer/index.html'));
424
+ });
425
+
426
+ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
427
+ `;
428
+
429
+ const viteConfig = (tailwind) => `import { defineConfig } from 'electron-vite';
430
+ import vue from '@vitejs/plugin-vue';
431
+ import { resolve } from 'path';
432
+ ${tailwind ? "import tailwindcss from 'tailwindcss';\nimport autoprefixer from 'autoprefixer';" : ''}
433
+ export default defineConfig({
434
+ main: { build: { lib: { entry: 'src/backend/main.js' } } },
435
+ preload: { build: { lib: { entry: 'src/backend/preload.ipc.js' } } },
436
+ renderer: {
437
+ root: 'src/frontend',
438
+ resolve: { alias: { '@': resolve('src/frontend') } },
439
+ plugins: [vue()],
440
+ ${tailwind ? "css: { postcss: { plugins: [tailwindcss(), autoprefixer()] } }," : ''}
441
+ },
442
+ });
443
+ `;
444
+
445
+ const pkgJson = (name, dbType, tailwind, eappVersion) => {
446
+ const deps = { electron: '^30.0.0', vue: '^3.4.0', 'vue-router': '^4.3.0', pinia: '^2.1.0' };
447
+ if (dbType === 'sqlite') deps['better-sqlite3'] = '^9.6.0';
448
+ if (dbType === 'mssql') deps['mssql'] = '^10.0.0';
449
+ if (dbType === 'mysql') deps['mysql2'] = '^3.9.0';
450
+ if (dbType === 'postgres') deps['pg'] = '^8.11.0';
451
+ const devDeps = {
452
+ '@vitejs/plugin-vue': '^5.0.0',
453
+ 'electron-vite': '^2.2.0',
454
+ vite: '^5.2.0',
455
+ '@cosider.construction/eapp': eappVersion || '*', // ← fix: add eapp CLI
456
+ };
457
+ if (tailwind) { devDeps.tailwindcss = '^3.4.0'; devDeps.autoprefixer = '^10.4.0'; devDeps.postcss = '^8.4.0'; }
458
+ return JSON.stringify({
459
+ name, version: '0.1.0', private: true, type: 'module',
460
+ main: 'src/backend/main.js',
461
+ scripts: { dev: 'electron-vite dev', build: 'electron-vite build', eapp: 'eapp' },
462
+ dependencies: deps, devDependencies: devDeps,
463
+ }, null, 2);
464
+ };
465
+
466
+ const appHome = () => `<script setup>
467
+ import { ref } from 'vue';
468
+ const gridConfig = ref([
469
+ { id: 'w1', col: 1, row: 1, colSpan: 2, rowSpan: 1, type: 'placeholder', title: 'Welcome' },
470
+ { id: 'w2', col: 3, row: 1, colSpan: 1, rowSpan: 1, type: 'placeholder', title: 'Stats' },
471
+ { id: 'w3', col: 1, row: 2, colSpan: 3, rowSpan: 1, type: 'placeholder', title: 'Activity' },
472
+ ]);
473
+ const COLS = 3;
474
+ function gridStyle(w) { return { gridColumn: \`\${w.col} / span \${w.colSpan}\`, gridRow: \`\${w.row} / span \${w.rowSpan}\` }; }
475
+ </script>
476
+ <template>
477
+ <div class="app-home">
478
+ <div class="home-header"><h1>Dashboard</h1><p class="sub">Welcome back</p></div>
479
+ <div class="widget-grid" :style="{ '--grid-cols': COLS }">
480
+ <div v-for="w in gridConfig" :key="w.id" class="widget" :style="gridStyle(w)">
481
+ <div class="widget__header"><span>{{ w.title }}</span></div>
482
+ <div class="widget__body"><div class="widget__ph"><span>{{ w.type }}</span><small>Configure in gridConfig</small></div></div>
483
+ </div>
484
+ </div>
485
+ </div>
486
+ </template>
487
+ <style scoped>
488
+ .app-home { padding:1.5rem;color:#cdd6f4; }
489
+ .home-header { margin-bottom:1.5rem; } h1 { font-size:1.5rem;font-weight:700;margin:0; }
490
+ .sub { color:#6c7086;margin:.25rem 0 0;font-size:.875rem; }
491
+ .widget-grid { display:grid;grid-template-columns:repeat(var(--grid-cols,3),1fr);gap:1rem; }
492
+ .widget { background:#313244;border:1px solid #45475a;border-radius:10px;display:flex;flex-direction:column;min-height:180px; }
493
+ .widget__header { padding:.75rem 1rem;border-bottom:1px solid #45475a;font-weight:600;font-size:.875rem; }
494
+ .widget__body { flex:1;padding:1rem; }
495
+ .widget__ph { height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;color:#585b70;font-size:.8rem; }
496
+ </style>
497
+ `;
498
+
499
+ // ─── Main ─────────────────────────────────────────────────────────────────────
500
+
501
+ async function main() {
502
+ const argv = minimist(process.argv.slice(2), {
503
+ boolean: ['tailwind', 'help', 'h'],
504
+ string: ['db', 'db-host', 'db-port', 'db-user', 'db-pass', 'db-name', 'db-mode', 'modules'],
505
+ });
506
+
507
+ if (argv.help || argv.h) { console.log(HELP); process.exit(0); }
508
+
509
+ let appName = argv._[0];
510
+ if (!appName) { console.error(chalk.red('Error: provide an app name.')); console.log(HELP); process.exit(1); }
511
+
512
+ const targetDir = path.resolve(process.cwd(), appName);
513
+ if (await fs.pathExists(targetDir)) {
514
+ console.error(chalk.red(`Directory "${appName}" already exists.`)); process.exit(1);
515
+ }
516
+
517
+ let { tailwind } = argv;
518
+ let dbType = argv.db;
519
+ let dbMode = argv['db-mode'] || 'single';
520
+ let connections = [];
521
+
522
+ // Interactive prompts if flags not provided
523
+ if (!dbType) {
524
+ if (!argv.tailwind) tailwind = await confirm({ message: 'Include Tailwind CSS?', default: false });
525
+ const embedded = await confirm({ message: 'Use embedded database (SQLite)?', default: true });
526
+ dbType = embedded ? 'sqlite' : null;
527
+
528
+ if (!embedded) {
529
+ dbType = await select({ message: 'Database type:', choices: [
530
+ { name: 'SQL Server (mssql)', value: 'mssql' },
531
+ { name: 'MySQL', value: 'mysql' },
532
+ { name: 'PostgreSQL', value: 'postgres' },
533
+ ]});
534
+ dbMode = await select({ message: 'Database mode:', choices: [
535
+ { name: 'Single database', value: 'single' },
536
+ { name: 'Multiple databases', value: 'multi' },
537
+ ]});
538
+ const count = dbMode === 'multi'
539
+ ? parseInt(await input({ message: 'How many connections?', default: '2' }), 10)
540
+ : 1;
541
+ for (let i = 0; i < count; i++) {
542
+ console.log(chalk.bold(`\nConnection ${i + 1}:`));
543
+ const name = count > 1 ? await input({ message: 'Name:' }) : appName;
544
+ const host = await input({ message: 'Host:', default: '127.0.0.1' });
545
+ const port = parseInt(await input({ message: 'Port:', default: dbType === 'mssql' ? '1433' : dbType === 'mysql' ? '3306' : '5432' }), 10);
546
+ const user = await input({ message: 'Username:' });
547
+ const pass = await password({ message: 'Password:' });
548
+ const database = await input({ message: 'Database name:' });
549
+ connections.push({ name, host, port, user, password: pass, database });
550
+ }
551
+ }
552
+ } else if (dbType !== 'sqlite') {
553
+ connections = [{ name: appName, host: argv['db-host'] || '127.0.0.1', port: parseInt(argv['db-port'] || '1433', 10), user: argv['db-user'] || '', password: argv['db-pass'] || '', database: argv['db-name'] || appName }];
554
+ }
555
+
556
+ if (dbType === 'sqlite') connections = [{ name: appName, database: 'local' }];
557
+
558
+ const modules = argv.modules ? argv.modules.split(',').map(m => m.trim().toLowerCase()) : [];
559
+ const multiDb = dbMode === 'multi';
560
+ const p = (...parts) => path.join(targetDir, ...parts);
561
+
562
+ console.log('');
563
+ console.log(chalk.bold.white('Creating:'), chalk.cyan(appName));
564
+ console.log(chalk.dim(` db: ${dbType} mode: ${dbMode} tailwind: ${tailwind} modules: ${modules.join(',') || 'none'}`));
565
+ console.log('');
566
+
567
+ // ── Write everything ───────────────────────────────────────────────────────
568
+ await write(p('package.json'), pkgJson(appName, dbType, tailwind, '1.0.0'));
569
+ await write(p('electron-vite.config.js'), viteConfig(tailwind));
570
+ await write(p('.gitignore'), `node_modules/\ndist/\nout/\n.env\n`);
571
+ await write(p('db.config.json'), JSON.stringify({ mode: dbMode, connections }, null, 2));
572
+ await write(p('app.config.json'), JSON.stringify({ mode: dbMode, connections }, null, 2));
573
+ if (tailwind) await write(p('tailwind.config.js'), `export default { content: ['./src/frontend/**/*.{vue,js}'], theme: { extend: {} }, plugins: [] };\n`);
574
+
575
+ // Engine
576
+ await write(p('src/backend/engine/auth.js'), engineAuth());
577
+ await write(p('src/backend/engine/db.js'), dbType === 'sqlite' ? engineDbSqlite() : engineDbExternal({ dbType, connections, mode: dbMode }));
578
+ await write(p('src/backend/engine/router.js'), `// Reserved for middleware extensions\n`);
579
+ await write(p('src/backend/engine/registry.js'), `const r = new Map();\nexport const register = (k,v) => r.set(k,v);\nexport const getAll = () => [...r.entries()];\nexport const has = (k) => r.has(k);\n`);
580
+ await write(p('src/backend/preload.ipc.js'), preloadIpc());
581
+ await write(p('src/backend/main.js'), mainJs());
582
+
583
+ // Backend loader
584
+ await fs.ensureDir(p('src/backend/loaders'));
585
+ await fs.copyFile(
586
+ path.join(stubsBase, 'loaders', 'loader.ipc.js'),
587
+ p('src/backend/loaders/loader.ipc.js')
588
+ );
589
+ console.log(chalk.green(' created'), chalk.dim('src/backend/loaders/loader.ipc.js'));
590
+
591
+ // Shared entities
592
+ for (const entity of ['USER', 'ROLE', 'PERMISSION']) {
593
+ const d = p('src/backend/shared', entity);
594
+ await write(path.join(d, 'model.js'), sharedModel(entity));
595
+ await write(path.join(d, 'controller.js'), sharedController(entity));
596
+ await write(path.join(d, 'ipc.json'), sharedIpc(entity));
597
+ }
598
+
599
+ // Frontend
600
+ await write(p('src/frontend/main.js'), `import { createApp } from 'vue';\nimport { createPinia } from 'pinia';\nimport App from './App.vue';\nimport router from './loaders/loader.routes.js';\nconst app = createApp(App);\napp.use(createPinia()).use(router).mount('#app');\n`);
601
+ await write(p('src/frontend/App.vue'), `<script setup>\nimport { RouterView } from 'vue-router';\n</script>\n<template><RouterView /></template>\n<style>*{box-sizing:border-box;margin:0;padding:0}body{font-family:system-ui,sans-serif;background:#1e1e2e;color:#cdd6f4}</style>\n`);
602
+ await write(p('src/frontend/index.html'), `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/><title>${appName}</title></head><body><div id="app"></div><script type="module" src="./main.js"></script></body></html>\n`);
603
+ await write(p('src/frontend/api/api.js'), baseApi());
604
+ await write(p('src/frontend/stores/auth.js'), authStore());
605
+ await write(p('src/frontend/router/index.js'), router());
606
+ await write(p('src/frontend/layouts/DefaultLayout.vue'), defaultLayout());
607
+ await write(p('src/frontend/menus/main.menu.json'), JSON.stringify({
608
+ module: 'main', label: 'Main',
609
+ hMenu: { style: 'classic', items: [{ label: 'Home', route: '/', icon: '' }] },
610
+ vMenu: { items: [] },
611
+ }, null, 2));
612
+ await write(p('src/frontend/views/LoginView.vue'), loginView(multiDb));
613
+ await write(p('src/frontend/views/AccessDeniedView.vue'), accessDenied());
614
+ await write(p('src/frontend/views/AppHome.vue'), appHome());
615
+ await fs.ensureDir(p('src/frontend/api/modules'));
616
+
617
+ // Copy frontend loaders
618
+ await fs.ensureDir(p('src/frontend/loaders'));
619
+ for (const file of ['loader.menu.js', 'loader.routes.js']) {
620
+ const src = path.join(stubsBase, 'loaders', file);
621
+ if (await fs.pathExists(src)) {
622
+ await fs.copyFile(src, p(`src/frontend/loaders/${file}`));
623
+ console.log(chalk.green(' created'), chalk.dim(`src/frontend/loaders/${file}`));
624
+ }
625
+ }
626
+
627
+ // Copy DefaultLayout from stubs
628
+ await fs.copyFile(
629
+ path.join(stubsBase, 'layouts', 'DefaultLayout.vue'),
630
+ p('src/frontend/layouts/DefaultLayout.vue')
631
+ );
632
+ console.log(chalk.green(' created'), chalk.dim('src/frontend/layouts/DefaultLayout.vue'));
633
+
634
+ // Dumb UI components
635
+ const compDest = p('src/frontend/components');
636
+ await fs.ensureDir(compDest);
637
+ for (const file of ['AppMenuH.vue', 'AppMenuV.vue', 'AppModal.vue', 'AppConfirm.vue', 'AppToast.vue']) {
638
+ const src = path.join(stubsBase, 'components', file);
639
+ if (await fs.pathExists(src)) {
640
+ await fs.copyFile(src, path.join(compDest, file));
641
+ console.log(chalk.green(' created'), chalk.dim(`src/frontend/components/${file}`));
642
+ }
643
+ }
644
+
645
+ await fs.ensureDir(p('public'));
646
+
647
+ // Modules
648
+ for (const mod of modules) {
649
+ const Pascal = mod.charAt(0).toUpperCase() + mod.slice(1);
650
+ await fs.ensureDir(p('src/backend/modules', mod.toUpperCase()));
651
+ await fs.ensureDir(p('src/frontend/views/modules', mod));
652
+
653
+ // Menu JSON with AppMenuH/AppMenuV structure
654
+ await write(p('src/frontend/menus', `${mod}.menu.json`), JSON.stringify({
655
+ module: mod,
656
+ label: Pascal,
657
+ hMenu: { style: 'classic', items: [{ label: `${Pascal} Home`, route: `/${mod}`, icon: '' }] },
658
+ vMenu: { items: [{ label: Pascal, icon: '', children: [] }] },
659
+ }, null, 2));
660
+
661
+ // Module layout
662
+ await write(p('src/frontend/layouts', `${Pascal}Layout.vue`), `<script setup>
663
+ import { inject } from 'vue';
664
+ import AppMenuH from '@/components/AppMenuH.vue';
665
+ import AppMenuV from '@/components/AppMenuV.vue';
666
+ import moduleMenu from '@/menus/${mod}.menu.json';
667
+ const subHMenu = inject('hMenuConfig', null);
668
+ const hMenuConfig = moduleMenu.hMenu || { style: 'classic', items: [] };
669
+ const vMenuConfig = moduleMenu.vMenu || { items: [] };
670
+ </script>
671
+ <template>
672
+ <div class="${mod}-layout">
673
+ <AppMenuH :config="hMenuConfig" />
674
+ <AppMenuH v-if="subHMenu" :config="subHMenu" sub />
675
+ <div class="${mod}-layout__body">
676
+ <AppMenuV :config="vMenuConfig" />
677
+ <main class="${mod}-layout__main"><slot /></main>
678
+ </div>
679
+ </div>
680
+ </template>
681
+ <style scoped>
682
+ .${mod}-layout { display:flex;flex-direction:column;height:100vh;overflow:hidden; }
683
+ .${mod}-layout__body { display:flex;flex:1;overflow:hidden; }
684
+ .${mod}-layout__main { flex:1;overflow:auto;padding:1.5rem;background:#1e1e2e; }
685
+ </style>
686
+ `);
687
+
688
+ // Module home page
689
+ await write(p('src/frontend/views/modules', mod, `${Pascal}Home.vue`), `<script setup>
690
+ import { ref } from 'vue';
691
+ const gridConfig = ref([
692
+ { id: 'w1', col: 1, row: 1, colSpan: 2, rowSpan: 1, type: 'placeholder', title: '${Pascal} Overview' },
693
+ { id: 'w2', col: 3, row: 1, colSpan: 1, rowSpan: 1, type: 'placeholder', title: 'Quick Stats' },
694
+ { id: 'w3', col: 1, row: 2, colSpan: 3, rowSpan: 1, type: 'placeholder', title: 'Recent Activity' },
695
+ ]);
696
+ const COLS = 3;
697
+ function gridStyle(w) { return { gridColumn: \`\${w.col} / span \${w.colSpan}\`, gridRow: \`\${w.row} / span \${w.rowSpan}\` }; }
698
+ </script>
699
+ <template>
700
+ <div class="${mod}-home">
701
+ <div class="home-header"><h1>${Pascal}</h1><p class="sub">Module overview</p></div>
702
+ <div class="widget-grid" :style="{ '--grid-cols': COLS }">
703
+ <div v-for="w in gridConfig" :key="w.id" class="widget" :style="gridStyle(w)">
704
+ <div class="widget__header"><span>{{ w.title }}</span></div>
705
+ <div class="widget__body"><div class="widget__ph"><span>{{ w.type }}</span><small>Configure in gridConfig</small></div></div>
706
+ </div>
707
+ </div>
708
+ </div>
709
+ </template>
710
+ <style scoped>
711
+ .${mod}-home { padding:1.5rem;color:#cdd6f4; }
712
+ .home-header { margin-bottom:1.5rem; } h1 { font-size:1.4rem;font-weight:700;margin:0; }
713
+ .sub { color:#6c7086;margin:.25rem 0 0;font-size:.875rem; }
714
+ .widget-grid { display:grid;grid-template-columns:repeat(var(--grid-cols,3),1fr);gap:1rem; }
715
+ .widget { background:#313244;border:1px solid #45475a;border-radius:10px;display:flex;flex-direction:column;min-height:160px; }
716
+ .widget__header { padding:.75rem 1rem;border-bottom:1px solid #45475a;font-weight:600;font-size:.875rem; }
717
+ .widget__body { flex:1;padding:1rem; }
718
+ .widget__ph { height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;color:#585b70;font-size:.8rem; }
719
+ </style>
720
+ `);
721
+ console.log(chalk.green(' module'), mod.toUpperCase());
722
+ }
723
+
724
+ console.log('');
725
+ console.log(chalk.bold.green('✔ Done!'));
726
+ console.log('');
727
+ console.log(chalk.cyan(` cd ${appName}`));
728
+ console.log(chalk.cyan(' npm install'));
729
+ console.log(chalk.cyan(' npm run dev'));
730
+ console.log('');
731
+ console.log(chalk.dim(' Then use the eapp CLI inside your project:'));
732
+ console.log(chalk.cyan(' eapp entity rh/employee'));
733
+ console.log(chalk.cyan(' eapp view rh/employee'));
734
+ console.log('');
735
+ }
736
+
737
+ main().catch((err) => {
738
+ if (err.name === 'ExitPromptError') { console.log(chalk.yellow('\nCancelled.')); process.exit(0); }
739
+ console.error(chalk.red('Error:'), err.message);
740
+ process.exit(1);
741
+ });