@cosider.construction/eapp 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.
@@ -0,0 +1,231 @@
1
+ import path from 'path';
2
+ import chalk from 'chalk';
3
+ import { toUpper, toLower, toPascal, isValidName, parseEntityPath } from '../utils/naming.js';
4
+ import { writeFile, writeJson, exists, assertProjectRoot, readDir, loadEntityRegistry } from '../utils/fs.js';
5
+ import { log } from '../utils/log.js';
6
+ import { textInput } from '../tui/engine.js';
7
+
8
+ // ─── Module ───────────────────────────────────────────────────────────────────
9
+
10
+ export async function generateModule(args, rawArgs) {
11
+ assertProjectRoot();
12
+ const dryRun = rawArgs.includes('--dry-run');
13
+ const schema = rawArgs[rawArgs.indexOf('--schema') + 1] || null;
14
+
15
+ let name = args[0] || await textInput('Module name:');
16
+ name = toLower(name.trim());
17
+
18
+ if (!isValidName(name)) { log.error('Invalid module name.'); process.exit(1); }
19
+
20
+ const cwd = process.cwd();
21
+ const backendDir = path.join(cwd, 'src', 'backend', 'modules', toUpper(name));
22
+ const frontendDir = path.join(cwd, 'src', 'frontend', 'views', 'modules', name);
23
+
24
+ if (!dryRun && await exists(backendDir)) {
25
+ log.warn(`Module ${toUpper(name)} already exists. Skipping.`); return;
26
+ }
27
+
28
+ await writeFile(path.join(backendDir, '.gitkeep'), '', { dryRun });
29
+ await writeFile(path.join(frontendDir, '.gitkeep'), '', { dryRun });
30
+
31
+ if (schema) {
32
+ await writeFile(path.join(backendDir, 'schema.sql'),
33
+ `-- Create schema for ${toUpper(name)} module\nIF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '${name}')\n EXEC('CREATE SCHEMA ${name}');\n`,
34
+ { dryRun });
35
+ }
36
+
37
+ // Menu file
38
+ const menuFile = path.join(cwd, 'src', 'frontend', 'menus', `${name}.menu.json`);
39
+ if (!(await exists(menuFile))) {
40
+ await writeJson(menuFile, { module: name, label: toPascal(name), hMenu: [], vMenu: [] }, { dryRun });
41
+ }
42
+
43
+ log.success(`Module ${chalk.cyan(toUpper(name))} created.`);
44
+ }
45
+
46
+ // ─── Menu (interactive table) ─────────────────────────────────────────────────
47
+
48
+ export async function generateMenu(args, rawArgs) {
49
+ assertProjectRoot();
50
+ const dryRun = rawArgs.includes('--dry-run');
51
+ const cwd = process.cwd();
52
+
53
+ // Load all existing menu entries from all menu files
54
+ const { fieldsTable } = await import('../tui/engine.js');
55
+ const fs = await import('fs-extra').then(m => m.default);
56
+
57
+ const menusDir = path.join(cwd, 'src', 'frontend', 'menus');
58
+ const menuFiles = (await fs.readdir(menusDir)).filter(f => f.endsWith('.menu.json'));
59
+
60
+ // Build flat list of all entries
61
+ const allEntries = [];
62
+ for (const file of menuFiles) {
63
+ const menu = await import('fs-extra').then(m => m.default.readJson(path.join(menusDir, file)));
64
+ const mod = menu.module || file.replace('.menu.json', '');
65
+ for (const item of (menu.hMenu || [])) {
66
+ const existing = allEntries.find(e => e.module === mod && e.entry === (item.label || item.route));
67
+ if (existing) { existing.hMenu = 'on'; }
68
+ else allEntries.push({ module: mod, entry: item.label || item.route, route: item.route, hMenu: 'on', vMenu: 'off', permission: item.permission || '' });
69
+ }
70
+ for (const item of (menu.vMenu || [])) {
71
+ const existing = allEntries.find(e => e.module === mod && e.entry === (item.label || item.route));
72
+ if (existing) { existing.vMenu = 'on'; }
73
+ else allEntries.push({ module: mod, entry: item.label || item.route, route: item.route, hMenu: 'off', vMenu: 'on', permission: item.permission || '' });
74
+ }
75
+ }
76
+
77
+ if (!allEntries.length) {
78
+ log.info('No menu entries yet. Generate some views with menu enabled first.');
79
+ return;
80
+ }
81
+
82
+ console.log('');
83
+ const updated = await fieldsTable(
84
+ allEntries,
85
+ ['module', 'entry', 'hMenu', 'vMenu'],
86
+ { module: 16, entry: 20, hMenu: 7, vMenu: 7 },
87
+ 'Menu entries (space to toggle hMenu/vMenu enter to save)'
88
+ );
89
+
90
+ // Write back to each menu file
91
+ if (!dryRun) {
92
+ const byModule = {};
93
+ for (const row of updated) {
94
+ if (!byModule[row.module]) byModule[row.module] = { hMenu: [], vMenu: [] };
95
+ const item = { label: row.entry, route: row.route, icon: '', permission: row.permission };
96
+ if (row.hMenu === 'on') byModule[row.module].hMenu.push(item);
97
+ if (row.vMenu === 'on') byModule[row.module].vMenu.push(item);
98
+ }
99
+ for (const [mod, data] of Object.entries(byModule)) {
100
+ const menuFile = path.join(menusDir, `${mod}.menu.json`);
101
+ const existing = await fs.pathExists(menuFile) ? await fs.readJson(menuFile) : { module: mod, label: toPascal(mod) };
102
+ await fs.writeJson(menuFile, { ...existing, ...data }, { spaces: 2 });
103
+ log.success(`Updated ${mod}.menu.json`);
104
+ }
105
+ } else {
106
+ log.info('[dry-run] would update menu files');
107
+ }
108
+ }
109
+
110
+ // ─── Pivot ────────────────────────────────────────────────────────────────────
111
+
112
+ export async function generatePivot(args, rawArgs) {
113
+ assertProjectRoot();
114
+ const dryRun = rawArgs.includes('--dry-run');
115
+
116
+ if (!args[0] || !args[1]) {
117
+ log.error('Usage: eapp pivot <module/entity-a> <module/entity-b>');
118
+ process.exit(1);
119
+ }
120
+
121
+ const a = parseEntityPath(args[0]);
122
+ const b = parseEntityPath(args[1]);
123
+
124
+ const sameModule = a.module === b.module && a.module !== 'shared';
125
+ const pivotModule = sameModule ? a.module : 'shared';
126
+ const pivotEntity = `${toUpper(a.entity)}_${toUpper(b.entity)}`;
127
+ const pivotLower = pivotEntity.toLowerCase();
128
+ const schema = sameModule ? a.schema : 'dbo';
129
+
130
+ const cwd = process.cwd();
131
+ const backendBase = sameModule
132
+ ? path.join(cwd, 'src', 'backend', 'modules', toUpper(pivotModule), pivotEntity)
133
+ : path.join(cwd, 'src', 'backend', 'shared', pivotEntity);
134
+
135
+ const fields = [
136
+ { field: `${a.entity}_id`, type: 'int', pk: false, fk: true, group: '', values: '' },
137
+ { field: `${b.entity}_id`, type: 'int', pk: false, fk: true, group: '', values: '' },
138
+ ];
139
+
140
+ // Simple model for pivot
141
+ const modelJs = `import { ${schema} as db } from '../../engine/db.js';
142
+
143
+ export async function getAll() { return db.query('SELECT * FROM ${pivotEntity}'); }
144
+ export async function getByA(${a.entity}_id) { return db.query('SELECT * FROM ${pivotEntity} WHERE ${a.entity}_id = @${a.entity}_id', { ${a.entity}_id }); }
145
+ export async function add(data) { return db.execute('INSERT INTO ${pivotEntity} (${a.entity}_id, ${b.entity}_id) VALUES (@${a.entity}_id, @${b.entity}_id)', data); }
146
+ export async function remove(${a.entity}_id, ${b.entity}_id) { return db.execute('DELETE FROM ${pivotEntity} WHERE ${a.entity}_id = @${a.entity}_id AND ${b.entity}_id = @${b.entity}_id', { ${a.entity}_id, ${b.entity}_id }); }
147
+ `;
148
+
149
+ await writeFile(path.join(backendBase, 'model.js'), modelJs, { dryRun });
150
+ await writeFile(path.join(backendBase, 'controller.js'), `import * as model from './model.js';\nexport const getAll = model.getAll;\nexport const getByA = model.getByA;\nexport const add = model.add;\nexport const remove = model.remove;\n`, { dryRun });
151
+ await writeJson(path.join(backendBase, 'ipc.json'), {
152
+ route: `${sameModule ? 'modules.' + toUpper(pivotModule) + '.' : 'shared.'}${pivotEntity}`,
153
+ schema,
154
+ methods: {
155
+ getAll: { handler: 'getAll' },
156
+ getByA: { handler: 'getByA' },
157
+ add: { handler: 'add' },
158
+ delete: { handler: 'remove' },
159
+ },
160
+ }, { dryRun });
161
+
162
+ log.success(`Pivot ${chalk.cyan(pivotEntity)} created in ${sameModule ? toUpper(pivotModule) : 'shared'}.`);
163
+ }
164
+
165
+ // ─── Layout ───────────────────────────────────────────────────────────────────
166
+
167
+ export async function generateLayout(args, rawArgs) {
168
+ assertProjectRoot();
169
+ const dryRun = rawArgs.includes('--dry-run');
170
+
171
+ let name = args[0] || await textInput('Layout name (e.g. rh, admin, default):');
172
+ name = name.trim();
173
+ if (!isValidName(name)) { log.error('Invalid layout name.'); process.exit(1); }
174
+
175
+ const cwd = process.cwd();
176
+ const Pascal = toPascal(name);
177
+ const filePath = path.join(cwd, 'src', 'frontend', 'layouts', `${Pascal}Layout.vue`);
178
+
179
+ if (!dryRun && await exists(filePath)) {
180
+ log.warn(`${Pascal}Layout.vue already exists — overwriting.`);
181
+ }
182
+
183
+ const stub = `<script setup>
184
+ // ${Pascal}Layout — page shell
185
+ </script>
186
+ <template>
187
+ <div class="${toLower(name)}-layout">
188
+ <header class="${toLower(name)}-layout__header"><!-- header --></header>
189
+ <main class="${toLower(name)}-layout__main"><slot /></main>
190
+ </div>
191
+ </template>
192
+ <style scoped>
193
+ .${toLower(name)}-layout { display:flex;flex-direction:column;min-height:100vh; }
194
+ .${toLower(name)}-layout__main { flex:1;padding:1rem; }
195
+ </style>
196
+ `;
197
+
198
+ await writeFile(filePath, stub, { dryRun });
199
+ log.success(`Layout ${chalk.cyan(Pascal + 'Layout.vue')} generated.`);
200
+ }
201
+
202
+ // ─── List commands ────────────────────────────────────────────────────────────
203
+
204
+ export async function listModules() {
205
+ assertProjectRoot();
206
+ const cwd = process.cwd();
207
+ const modules = await readDir(path.join(cwd, 'src', 'backend', 'modules'));
208
+ if (!modules.length) { log.info('No modules found.'); return; }
209
+ console.log(chalk.bold('\nModules:'));
210
+ modules.forEach(m => console.log(' ' + chalk.cyan(m)));
211
+ }
212
+
213
+ export async function listEntities(args) {
214
+ assertProjectRoot();
215
+ const registry = await loadEntityRegistry(process.cwd());
216
+ const filter = args[0] ? toLower(args[0]) : null;
217
+
218
+ const entries = [...registry.entries()]
219
+ .filter(([key]) => !filter || key.startsWith(filter + '.'))
220
+ .sort(([a], [b]) => a.localeCompare(b));
221
+
222
+ if (!entries.length) { log.info('No entities found.'); return; }
223
+ console.log(chalk.bold('\nEntities:'));
224
+ entries.forEach(([, meta]) => {
225
+ console.log(
226
+ ' ' + chalk.cyan(meta.module) + chalk.dim('/') +
227
+ chalk.white(meta.entity) + chalk.dim('.' + meta.schema) +
228
+ chalk.dim(' pk:' + meta.pkField)
229
+ );
230
+ });
231
+ }
@@ -0,0 +1,202 @@
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+ import chalk from 'chalk';
4
+ import { toUpper, toLower, toPascal, isValidName } from '../utils/naming.js';
5
+ import { writeFile, writeJson, exists, readDir, assertProjectRoot } from '../utils/fs.js';
6
+ import { log } from '../utils/log.js';
7
+ import { textInput, fieldsTable } from '../tui/engine.js';
8
+
9
+ // ─── Stubs ────────────────────────────────────────────────────────────────────
10
+
11
+ function moduleLayoutStub(name) {
12
+ const Pascal = toPascal(name);
13
+ return `<script setup>
14
+ import { inject } from 'vue';
15
+ import AppMenuH from '@/components/AppMenuH.vue';
16
+ import AppMenuV from '@/components/AppMenuV.vue';
17
+ import moduleMenu from '@/menus/${toLower(name)}.menu.json';
18
+
19
+ const subHMenu = inject('hMenuConfig', null);
20
+ const hMenuConfig = moduleMenu.hMenu || { style: 'classic', items: [] };
21
+ const vMenuConfig = moduleMenu.vMenu || { items: [] };
22
+ </script>
23
+
24
+ <template>
25
+ <div class="${toLower(name)}-layout">
26
+ <AppMenuH :config="hMenuConfig" />
27
+ <AppMenuH v-if="subHMenu" :config="subHMenu" sub />
28
+ <div class="${toLower(name)}-layout__body">
29
+ <AppMenuV :config="vMenuConfig" />
30
+ <main class="${toLower(name)}-layout__main"><slot /></main>
31
+ </div>
32
+ </div>
33
+ </template>
34
+
35
+ <style scoped>
36
+ .${toLower(name)}-layout { display:flex;flex-direction:column;height:100vh;overflow:hidden; }
37
+ .${toLower(name)}-layout__body { display:flex;flex:1;overflow:hidden; }
38
+ .${toLower(name)}-layout__main { flex:1;overflow:auto;padding:1.5rem;background:#1e1e2e; }
39
+ </style>
40
+ `;
41
+ }
42
+
43
+ function moduleHomeStub(name) {
44
+ const label = toPascal(name);
45
+ return `<script setup>
46
+ import { ref } from 'vue';
47
+
48
+ const gridConfig = ref([
49
+ { id: 'w1', col: 1, row: 1, colSpan: 2, rowSpan: 1, type: 'placeholder', title: '${label} Overview' },
50
+ { id: 'w2', col: 3, row: 1, colSpan: 1, rowSpan: 1, type: 'placeholder', title: 'Quick Stats' },
51
+ { id: 'w3', col: 1, row: 2, colSpan: 3, rowSpan: 1, type: 'placeholder', title: 'Recent Activity' },
52
+ ]);
53
+
54
+ const COLS = 3;
55
+ function gridStyle(w) {
56
+ return { gridColumn: \`\${w.col} / span \${w.colSpan}\`, gridRow: \`\${w.row} / span \${w.rowSpan}\` };
57
+ }
58
+ </script>
59
+
60
+ <template>
61
+ <div class="${toLower(name)}-home">
62
+ <div class="home-header">
63
+ <h1>${label}</h1>
64
+ <p class="sub">Module overview</p>
65
+ </div>
66
+ <div class="widget-grid" :style="{ '--grid-cols': COLS }">
67
+ <div v-for="w in gridConfig" :key="w.id" class="widget" :style="gridStyle(w)">
68
+ <div class="widget__header"><span>{{ w.title }}</span></div>
69
+ <div class="widget__body">
70
+ <div class="widget__placeholder">
71
+ <span>{{ w.type }}</span><small>Configure in gridConfig</small>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </template>
78
+
79
+ <style scoped>
80
+ .${toLower(name)}-home { padding:1.5rem;color:#cdd6f4; }
81
+ .home-header { margin-bottom:1.5rem; }
82
+ h1 { font-size:1.4rem;font-weight:700;margin:0; }
83
+ .sub { color:#6c7086;margin:.25rem 0 0;font-size:.875rem; }
84
+ .widget-grid { display:grid;grid-template-columns:repeat(var(--grid-cols,3),1fr);gap:1rem; }
85
+ .widget { background:#313244;border:1px solid #45475a;border-radius:10px;overflow:hidden;display:flex;flex-direction:column;min-height:160px; }
86
+ .widget__header { padding:.75rem 1rem;border-bottom:1px solid #45475a; }
87
+ .widget__body { flex:1;padding:1rem; }
88
+ .widget__placeholder { height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;color:#585b70;font-size:.8rem; }
89
+ </style>
90
+ `;
91
+ }
92
+
93
+ function moduleMenuJson(name) {
94
+ return {
95
+ module: toLower(name),
96
+ label: toPascal(name),
97
+ hMenu: { style: 'classic', items: [
98
+ { label: `${toPascal(name)} Home`, route: `/${toLower(name)}`, icon: '' },
99
+ ]},
100
+ vMenu: { items: [
101
+ { label: toPascal(name), icon: '', children: [] },
102
+ ]},
103
+ };
104
+ }
105
+
106
+ // ─── Generator ────────────────────────────────────────────────────────────────
107
+
108
+ export async function generateModule(args, rawArgs) {
109
+ assertProjectRoot();
110
+
111
+ const dryRun = rawArgs.includes('--dry-run');
112
+ const cwd = process.cwd();
113
+
114
+ // ── Load existing modules for table ───────────────────────────────────────
115
+ const modulesDir = path.join(cwd, 'src', 'backend', 'modules');
116
+ const existing = await readDir(modulesDir);
117
+
118
+ // Build table rows from existing modules
119
+ const rows = existing.map(mod => {
120
+ const lower = toLower(mod);
121
+ return {
122
+ module: lower,
123
+ layout: `${toPascal(lower)}Layout`,
124
+ vmenu: 'own',
125
+ home: 'on',
126
+ grid: 'on',
127
+ };
128
+ });
129
+
130
+ // Add a new empty row if a name was provided or will be typed
131
+ let newName = args[0] || '';
132
+
133
+ if (!newName && !existing.length) {
134
+ newName = await textInput('Module name (e.g. rh, sales, stk):');
135
+ }
136
+
137
+ if (newName) {
138
+ newName = toLower(newName.trim());
139
+ if (!isValidName(newName)) { log.error('Invalid module name.'); process.exit(1); }
140
+ if (!existing.find(e => toLower(e) === newName)) {
141
+ rows.push({ module: newName, layout: `${toPascal(newName)}Layout`, vmenu: 'own', home: 'on', grid: 'on' });
142
+ }
143
+ }
144
+
145
+ // ── Show module table ─────────────────────────────────────────────────────
146
+ console.log('');
147
+ const updated = await fieldsTable(
148
+ rows,
149
+ ['module', 'layout', 'vmenu', 'home', 'grid'],
150
+ { module: 14, layout: 18, vmenu: 8, home: 6, grid: 6 },
151
+ 'Modules (edit layout name, vmenu: own|share, home/grid: on|off enter to save)'
152
+ );
153
+
154
+ // ── Write new/updated modules ──────────────────────────────────────────────
155
+ for (const row of updated) {
156
+ const name = toLower(row.module);
157
+ const hasHome = row.home !== 'off';
158
+ const hasGrid = row.grid !== 'off';
159
+ const ownVMenu = row.vmenu !== 'share';
160
+ const layoutName = row.layout || `${toPascal(name)}Layout`;
161
+
162
+ const backendDir = path.join(cwd, 'src', 'backend', 'modules', toUpper(name));
163
+ const frontendDir = path.join(cwd, 'src', 'frontend', 'views', 'modules', name);
164
+ const layoutFile = path.join(cwd, 'src', 'frontend', 'layouts', `${layoutName}.vue`);
165
+ const menuFile = path.join(cwd, 'src', 'frontend', 'menus', `${name}.menu.json`);
166
+ const homeFile = path.join(cwd, 'src', 'frontend', 'views', 'modules', name, `${toPascal(name)}Home.vue`);
167
+ const routerFile = path.join(cwd, 'src', 'frontend', 'router', 'index.js');
168
+
169
+ // Backend dir
170
+ if (!(await exists(backendDir))) await writeFile(path.join(backendDir, '.gitkeep'), '', { dryRun });
171
+ if (!(await exists(frontendDir))) await writeFile(path.join(frontendDir, '.gitkeep'), '', { dryRun });
172
+
173
+ // Menu JSON
174
+ if (!(await exists(menuFile))) {
175
+ await writeJson(menuFile, moduleMenuJson(name), { dryRun });
176
+ }
177
+
178
+ // Layout
179
+ if (!(await exists(layoutFile))) {
180
+ await writeFile(layoutFile, moduleLayoutStub(name), { dryRun });
181
+ }
182
+
183
+ // Module home
184
+ if (hasHome && !(await exists(homeFile))) {
185
+ await writeFile(homeFile, moduleHomeStub(name), { dryRun });
186
+ }
187
+
188
+ // Router entry for module home
189
+ if (hasHome && !dryRun && await exists(routerFile)) {
190
+ const content = await fs.readFile(routerFile, 'utf8');
191
+ const route = `/${name}`;
192
+ if (!content.includes(`path: '${route}'`)) {
193
+ const entry = ` {\n path: '${route}',\n component: () => import('@/views/modules/${name}/${toPascal(name)}Home.vue'),\n },`;
194
+ const updated = content.replace(/\/\/ routes/i, `// routes\n${entry}`);
195
+ await fs.writeFile(routerFile, updated, 'utf8');
196
+ log.success(`Added route ${route} to router`);
197
+ }
198
+ }
199
+
200
+ log.success(`Module ${chalk.cyan(toUpper(name))} ${existing.find(e => toLower(e) === name) ? 'updated' : 'created'}.`);
201
+ }
202
+ }