@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.
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@cosider.construction/eapp",
3
+ "version": "1.0.0",
4
+ "description": "Project-local CLI for Electron + Vue ERP projects",
5
+ "type": "module",
6
+ "bin": {
7
+ "eapp": "./src/index.js"
8
+ },
9
+ "files": ["src"],
10
+ "dependencies": {
11
+ "chalk": "^5.3.0",
12
+ "fs-extra": "^11.2.0",
13
+ "minimist": "^1.2.8"
14
+ }
15
+ }
@@ -0,0 +1,71 @@
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+ import chalk from 'chalk';
4
+ import { assertProjectRoot, exists } from '../utils/fs.js';
5
+ import { log } from '../utils/log.js';
6
+ import { fieldsTable, radioLine } from '../tui/engine.js';
7
+
8
+ export async function generateConfig(args, rawArgs) {
9
+ assertProjectRoot();
10
+
11
+ const cwd = process.cwd();
12
+ const configFile = path.join(cwd, 'db.config.json');
13
+
14
+ let config = { mode: 'single', connections: [] };
15
+ if (await exists(configFile)) {
16
+ config = await fs.readJson(configFile);
17
+ }
18
+
19
+ // ── Mode toggle ───────────────────────────────────────────────────────────
20
+ console.log('');
21
+ console.log(chalk.bold.white(' Database mode'));
22
+ const mode = await radioLine('', [
23
+ { label: 'Single database', value: 'single' },
24
+ { label: 'Multi database', value: 'multi' },
25
+ ], config.mode === 'multi' ? 1 : 0);
26
+
27
+ // ── Connections table ─────────────────────────────────────────────────────
28
+ const rows = (config.connections || []).map(c => ({
29
+ name: c.name || '',
30
+ host: c.host || '',
31
+ port: String(c.port || 1433),
32
+ user: c.user || '',
33
+ password: c.password ? '••••••' : '',
34
+ database: c.database || '',
35
+ // store real password separately so we don't lose it on ••••••
36
+ _realPass: c.password || '',
37
+ }));
38
+
39
+ if (!rows.length) {
40
+ rows.push({ name: 'default', host: '127.0.0.1', port: '1433', user: '', password: '', database: '', _realPass: '' });
41
+ }
42
+
43
+ console.log('');
44
+ const updated = await fieldsTable(
45
+ rows,
46
+ ['name', 'host', 'port', 'user', 'password', 'database'],
47
+ { name: 12, host: 16, port: 6, user: 10, password: 10, database: 14 },
48
+ 'Connections (+ add - remove ← → cols ↑ ↓ rows enter save)'
49
+ );
50
+
51
+ // Restore real passwords where user left ••••••
52
+ const connections = updated.map((r, i) => ({
53
+ name: r.name,
54
+ host: r.host,
55
+ port: parseInt(r.port, 10) || 1433,
56
+ user: r.user,
57
+ password: r.password === '••••••' ? (rows[i]?._realPass || '') : r.password,
58
+ database: r.database,
59
+ }));
60
+
61
+ const newConfig = { mode, connections };
62
+ await fs.writeJson(configFile, newConfig, { spaces: 2 });
63
+ log.success('db.config.json updated.');
64
+
65
+ // Also update app.config.json if it exists
66
+ const appConfigFile = path.join(cwd, 'app.config.json');
67
+ if (await exists(appConfigFile)) {
68
+ await fs.writeJson(appConfigFile, newConfig, { spaces: 2 });
69
+ log.success('app.config.json updated.');
70
+ }
71
+ }
@@ -0,0 +1,256 @@
1
+ import path from 'path';
2
+ import chalk from 'chalk';
3
+ import { parseEntityPath, ipcRoute, toUpper, toLower, toPascal } from '../utils/naming.js';
4
+ import { writeFile, writeJson, exists, assertProjectRoot, loadEntityRegistry } from '../utils/fs.js';
5
+ import { log } from '../utils/log.js';
6
+ import { textInput, optionsLine, fieldsTable, radioLine } from '../tui/engine.js';
7
+
8
+ // ─── Default fields ───────────────────────────────────────────────────────────
9
+
10
+ function defaultFillableRows(entityName) {
11
+ return [
12
+ { field: `${entityName}_id`, type: 'int', values: '', pk: true, fk: false, group: '' },
13
+ { field: `${entityName}_lib`, type: 'varchar', values: '', pk: false, fk: false, group: '' },
14
+ ];
15
+ }
16
+
17
+ const DEFAULT_AUDIT_ROWS = [
18
+ { field: 'dt_creation', type: 'datetime' },
19
+ { field: 'user_creation', type: 'varchar' },
20
+ { field: 'dt_modif', type: 'datetime' },
21
+ { field: 'user_modif', type: 'varchar' },
22
+ ];
23
+
24
+ const FILLABLE_COLS = ['field', 'type', 'values', 'pk', 'fk', 'group'];
25
+ const FILLABLE_WIDTHS = { field: 18, type: 10, values: 14, pk: 4, fk: 4, group: 14 };
26
+ const AUDIT_COLS = ['field', 'type'];
27
+ const AUDIT_WIDTHS = { field: 20, type: 10 };
28
+
29
+ // ─── Code generators ──────────────────────────────────────────────────────────
30
+
31
+ function buildModelJs({ module, entity, schema, fields, auditFields, pkField }) {
32
+ const TABLE = toUpper(entity);
33
+ const dbAlias = schema === 'dbo' ? 'dbo' : schema;
34
+ const importPath = module === 'shared' ? '../../../engine/db.js' : '../../engine/db.js';
35
+
36
+ const allFields = [...fields, ...auditFields];
37
+ const fillable = fields.filter(f => !f.pk);
38
+ const insertCols = fillable.map(f => f.field).join(', ');
39
+ const insertVals = fillable.map(f => `@${f.field}`).join(', ');
40
+ const updateSet = fillable.map(f => `${f.field} = @${f.field}`).join(', ');
41
+
42
+ return `import { ${dbAlias} as db } from '${importPath}';
43
+
44
+ // Table : ${TABLE}
45
+ // Schema : ${schema}
46
+ // PK : ${pkField}
47
+
48
+ export async function getAll() {
49
+ return db.query('SELECT * FROM ${TABLE}');
50
+ }
51
+
52
+ export async function getById(${pkField}) {
53
+ return db.queryOne('SELECT * FROM ${TABLE} WHERE ${pkField} = @${pkField}', { ${pkField} });
54
+ }
55
+
56
+ export async function add(data) {
57
+ const { ${fillable.map(f => f.field).join(', ') || '/* fields */'} } = data;
58
+ return db.execute(
59
+ 'INSERT INTO ${TABLE} (${insertCols || '/* cols */'}) VALUES (${insertVals || '/* vals */'})',
60
+ data
61
+ );
62
+ }
63
+
64
+ export async function update(${pkField}, data) {
65
+ return db.execute(
66
+ 'UPDATE ${TABLE} SET ${updateSet || '/* set */'} WHERE ${pkField} = @${pkField}',
67
+ { ...data, ${pkField} }
68
+ );
69
+ }
70
+
71
+ export async function remove(${pkField}) {
72
+ return db.execute('DELETE FROM ${TABLE} WHERE ${pkField} = @${pkField}', { ${pkField} });
73
+ }
74
+ `;
75
+ }
76
+
77
+ function buildControllerJs({ pkField }) {
78
+ return `import * as model from './model.js';
79
+
80
+ export async function getAll() { return model.getAll(); }
81
+ export async function getById(${pkField}) { return model.getById(${pkField}); }
82
+ export async function add(data) { return model.add(data); }
83
+ export async function update(${pkField}, data) { return model.update(${pkField}, data); }
84
+ export async function remove(${pkField}) { return model.remove(${pkField}); }
85
+ `;
86
+ }
87
+
88
+ function buildIpcJson({ module, entity, schema }) {
89
+ const route = ipcRoute(module, entity);
90
+ const mod = module === 'shared' ? 'shared' : module;
91
+ const ent = toLower(entity);
92
+ return JSON.stringify({
93
+ route,
94
+ schema,
95
+ methods: {
96
+ getAll: { handler: 'getAll', permission: `${mod}.${ent}.view` },
97
+ getById: { handler: 'getById', permission: `${mod}.${ent}.view` },
98
+ add: { handler: 'add', permission: `${mod}.${ent}.add` },
99
+ update: { handler: 'update', permission: `${mod}.${ent}.update` },
100
+ delete: { handler: 'remove', permission: `${mod}.${ent}.delete` },
101
+ },
102
+ }, null, 2);
103
+ }
104
+
105
+ function buildApiJs({ module, entity }) {
106
+ const route = ipcRoute(module, entity);
107
+ const ClassName = toPascal(entity) + 'Api';
108
+ return `import { Api } from '../../api.js';
109
+
110
+ class ${ClassName} extends Api {
111
+ constructor() { super('${route}'); }
112
+ }
113
+
114
+ export default new ${ClassName}();
115
+ `;
116
+ }
117
+
118
+ function buildMetaJson({ module, entity, schema, fields, auditFields, pkField }) {
119
+ return JSON.stringify({ module, entity, schema, fields, auditFields, pkField }, null, 2);
120
+ }
121
+
122
+ // ─── Write entity files ───────────────────────────────────────────────────────
123
+
124
+ async function writeEntity({ module, entity, schema, fields, auditFields, pkField, opts, dryRun }) {
125
+ const cwd = process.cwd();
126
+ const backendBase = module === 'shared'
127
+ ? path.join(cwd, 'src', 'backend', 'shared', toUpper(entity))
128
+ : path.join(cwd, 'src', 'backend', 'modules', toUpper(module), toUpper(entity));
129
+
130
+ const o = opts || { model: true, controller: true, crud: true, api: true, ipc: true };
131
+
132
+ if (o.model) {
133
+ await writeFile(path.join(backendBase, 'model.js'),
134
+ buildModelJs({ module, entity, schema, fields, auditFields, pkField }), { dryRun });
135
+ }
136
+ if (o.controller) {
137
+ await writeFile(path.join(backendBase, 'controller.js'),
138
+ buildControllerJs({ pkField }), { dryRun });
139
+ }
140
+ if (o.ipc) {
141
+ await writeFile(path.join(backendBase, 'ipc.json'),
142
+ buildIpcJson({ module, entity, schema }), { dryRun });
143
+ }
144
+ // Always write meta so registry can read it
145
+ await writeJson(path.join(backendBase, 'entity.meta.json'),
146
+ JSON.parse(buildMetaJson({ module, entity, schema, fields, auditFields, pkField })), { dryRun });
147
+
148
+ if (o.api && o.ipc) {
149
+ const apiPath = path.join(cwd, 'src', 'frontend', 'api', 'modules',
150
+ `${toLower(module)}.${toLower(entity)}.api.js`);
151
+ await writeFile(apiPath, buildApiJs({ module, entity }), { dryRun });
152
+ }
153
+
154
+ log.success(`Entity ${chalk.cyan(module + '/' + entity + '.' + schema)} generated.`);
155
+ }
156
+
157
+ // ─── Main generator ───────────────────────────────────────────────────────────
158
+
159
+ export async function generateEntity(args, rawArgs) {
160
+ assertProjectRoot();
161
+
162
+ const dryRun = rawArgs.includes('--dry-run');
163
+ const useDefault = rawArgs.includes('--default');
164
+
165
+ // ── Show hint if no args ──────────────────────────────────────────────────
166
+ if (!args[0] && !useDefault) {
167
+ console.log('');
168
+ console.log(chalk.bold.cyan(' eapp entity') + chalk.dim(' <module/entity.schema>'));
169
+ console.log('');
170
+ console.log(chalk.dim(' /employee → shared/employee.dbo'));
171
+ console.log(chalk.dim(' rh/employee → rh module, rh schema'));
172
+ console.log(chalk.dim(' rh/employee.dbo → rh module, dbo schema'));
173
+ console.log(chalk.dim(' rh/employee.hr → rh module, custom schema hr'));
174
+ console.log('');
175
+ }
176
+
177
+ // ── Parse or prompt for path ──────────────────────────────────────────────
178
+ let parsed;
179
+ if (args[0]) {
180
+ try { parsed = parseEntityPath(args[0]); }
181
+ catch (e) { log.error(e.message); process.exit(1); }
182
+ } else {
183
+ const input = await textInput('module/entity.schema:');
184
+ if (!input) { log.warn('Cancelled.'); return; }
185
+ try { parsed = parseEntityPath(input); }
186
+ catch (e) { log.error(e.message); process.exit(1); }
187
+ }
188
+
189
+ const { module, entity, schema } = parsed;
190
+ const pkField = `${entity}_id`;
191
+
192
+ // ── Check if exists ───────────────────────────────────────────────────────
193
+ const cwd = process.cwd();
194
+ const backendBase = module === 'shared'
195
+ ? path.join(cwd, 'src', 'backend', 'shared', toUpper(entity))
196
+ : path.join(cwd, 'src', 'backend', 'modules', toUpper(module), toUpper(entity));
197
+
198
+ const entityExists = await exists(backendBase);
199
+ if (entityExists) {
200
+ log.info(`Entity ${chalk.cyan(module + '/' + entity)} already exists.`);
201
+ log.dim(' Regenerating files — existing logic will be preserved in model/controller.');
202
+ }
203
+
204
+ // ── --default: skip all prompts ───────────────────────────────────────────
205
+ if (useDefault) {
206
+ await writeEntity({
207
+ module, entity, schema,
208
+ fields: defaultFillableRows(entity),
209
+ auditFields: DEFAULT_AUDIT_ROWS,
210
+ pkField,
211
+ opts: { model: true, controller: true, crud: true, api: true, ipc: true },
212
+ dryRun,
213
+ });
214
+ return;
215
+ }
216
+
217
+ // ── Options ───────────────────────────────────────────────────────────────
218
+ console.log('');
219
+ console.log(chalk.bold.white(' Options'));
220
+ const opts = await optionsLine([
221
+ { key: 'model', label: 'Model', default: true },
222
+ { key: 'controller', label: 'Controller', default: true },
223
+ { key: 'crud', label: 'CRUD', default: true },
224
+ { key: 'api', label: 'API', default: true },
225
+ { key: 'ipc', label: 'IPC', default: true },
226
+ ]);
227
+
228
+ // ── Fillable fields ───────────────────────────────────────────────────────
229
+ const fillableRows = await fieldsTable(
230
+ defaultFillableRows(entity),
231
+ FILLABLE_COLS,
232
+ FILLABLE_WIDTHS,
233
+ 'Fillable Fields (+ add - remove ← → cols ↑ ↓ rows space pk/fk enter done)'
234
+ );
235
+
236
+ // ── Audit fields ──────────────────────────────────────────────────────────
237
+ const auditRows = await fieldsTable(
238
+ [...DEFAULT_AUDIT_ROWS],
239
+ AUDIT_COLS,
240
+ AUDIT_WIDTHS,
241
+ 'Audit Fields (non-fillable) (- to remove unwanted)'
242
+ );
243
+
244
+ // Find PK
245
+ const pkRow = fillableRows.find(r => r.pk);
246
+ const resolvedPk = pkRow ? pkRow.field : pkField;
247
+
248
+ await writeEntity({
249
+ module, entity, schema,
250
+ fields: fillableRows,
251
+ auditFields: auditRows,
252
+ pkField: resolvedPk,
253
+ opts,
254
+ dryRun,
255
+ });
256
+ }
@@ -0,0 +1,230 @@
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+ import chalk from 'chalk';
4
+ import { parseEntityPath, toLower, toPascal } from '../utils/naming.js';
5
+ import { writeJson, exists, assertProjectRoot } from '../utils/fs.js';
6
+ import { log } from '../utils/log.js';
7
+ import { fieldsTable, radioLine, textInput } from '../tui/engine.js';
8
+
9
+ // ─── Flatten nested menu items into table rows ────────────────────────────────
10
+
11
+ function flattenItems(items = [], parentLabel = '') {
12
+ const rows = [];
13
+ for (const item of items) {
14
+ rows.push({
15
+ label: item.label || '',
16
+ route: item.route || '',
17
+ icon: item.icon || '',
18
+ parent: parentLabel,
19
+ permission: item.permission || '',
20
+ });
21
+ if (item.children?.length) {
22
+ rows.push(...flattenItems(item.children, item.label));
23
+ }
24
+ }
25
+ return rows;
26
+ }
27
+
28
+ // ─── Rebuild nested menu items from flat rows ─────────────────────────────────
29
+
30
+ function nestItems(rows) {
31
+ const top = rows.filter(r => !r.parent);
32
+ const children = rows.filter(r => r.parent);
33
+
34
+ return top.map(r => {
35
+ const item = { label: r.label, route: r.route, icon: r.icon };
36
+ if (r.permission) item.permission = r.permission;
37
+ const kids = children.filter(c => c.parent === r.label);
38
+ if (kids.length) {
39
+ item.children = kids.map(c => ({
40
+ label: c.label, route: c.route, icon: c.icon,
41
+ ...(c.permission ? { permission: c.permission } : {}),
42
+ }));
43
+ }
44
+ return item;
45
+ });
46
+ }
47
+
48
+ // ─── View-level ribbon/classic menu editor ────────────────────────────────────
49
+
50
+ async function editViewMenu(menuFile, existing) {
51
+ console.log('');
52
+ console.log(chalk.bold.white(' View menu style'));
53
+ const style = await radioLine('', [
54
+ { label: 'Classic (dropdown)', value: 'classic' },
55
+ { label: 'Ribbon (Office)', value: 'ribbon' },
56
+ ], existing?.hMenu?.style === 'ribbon' ? 1 : 0);
57
+
58
+ if (style === 'classic') {
59
+ // Classic — same item table as module menu
60
+ const existing_items = flattenItems(existing?.hMenu?.items || []);
61
+ if (!existing_items.length) {
62
+ existing_items.push({ label: 'Action', route: '', icon: '', parent: '', permission: '' });
63
+ }
64
+ console.log('');
65
+ const rows = await fieldsTable(
66
+ existing_items,
67
+ ['label', 'route', 'icon', 'parent', 'permission'],
68
+ { label: 16, route: 20, icon: 6, parent: 14, permission: 20 },
69
+ 'hMenu items (parent = dropdown group label, empty = top level)'
70
+ );
71
+ const menu = { route: existing?.route || '', hMenu: { style: 'classic', items: nestItems(rows) } };
72
+ await writeJson(menuFile, menu);
73
+ log.success('View menu updated.');
74
+ return;
75
+ }
76
+
77
+ // Ribbon — tabs → groups → items
78
+ const tabs = existing?.hMenu?.tabs || [{ label: 'General', groups: [] }];
79
+ console.log('');
80
+ const tabRows = await fieldsTable(
81
+ tabs.map(t => ({ label: t.label })),
82
+ ['label'],
83
+ { label: 20 },
84
+ 'Ribbon tabs (+ add - remove enter done)'
85
+ );
86
+
87
+ const builtTabs = [];
88
+ for (const tabRow of tabRows) {
89
+ const existingTab = tabs.find(t => t.label === tabRow.label) || { groups: [] };
90
+ console.log('');
91
+ console.log(chalk.bold.white(` Tab: ${tabRow.label} — Groups`));
92
+ const groupRows = await fieldsTable(
93
+ (existingTab.groups || [{ label: 'Actions' }]).map(g => ({ label: g.label })),
94
+ ['label'],
95
+ { label: 20 },
96
+ 'Groups (+ add - remove enter done)'
97
+ );
98
+
99
+ const builtGroups = [];
100
+ for (const groupRow of groupRows) {
101
+ const existingGroup = (existingTab.groups || []).find(g => g.label === groupRow.label) || { items: [] };
102
+ console.log('');
103
+ console.log(chalk.bold.white(` Group: ${groupRow.label} — Items`));
104
+ const itemRows = await fieldsTable(
105
+ (existingGroup.items || [
106
+ { label: 'New', action: 'emit:new', icon: '+' },
107
+ { label: 'Save', action: 'emit:save', icon: '💾' },
108
+ { label: 'Delete', action: 'emit:delete', icon: '🗑' },
109
+ ]),
110
+ ['label', 'action', 'icon'],
111
+ { label: 14, action: 20, icon: 6 },
112
+ 'Items (action: emit:name | route:/path)'
113
+ );
114
+ builtGroups.push({ label: groupRow.label, items: itemRows });
115
+ }
116
+ builtTabs.push({ label: tabRow.label, groups: builtGroups });
117
+ }
118
+
119
+ const menu = { route: existing?.route || '', hMenu: { style: 'ribbon', tabs: builtTabs } };
120
+ await writeJson(menuFile, menu);
121
+ log.success('View ribbon menu updated.');
122
+ }
123
+
124
+ // ─── Main generator ───────────────────────────────────────────────────────────
125
+
126
+ export async function generateMenu(args, rawArgs) {
127
+ assertProjectRoot();
128
+ const cwd = process.cwd();
129
+
130
+ const target = args[0]; // e.g. "rh" or "rh/employee"
131
+
132
+ // ── Determine if this is a view-level or module-level menu ────────────────
133
+ let isViewMenu = false;
134
+ let module = '';
135
+ let entity = '';
136
+
137
+ if (target) {
138
+ if (target.includes('/')) {
139
+ // view-level: rh/employee
140
+ try {
141
+ const parsed = parseEntityPath(target);
142
+ module = parsed.module;
143
+ entity = parsed.entity;
144
+ isViewMenu = true;
145
+ } catch (e) {
146
+ // treat as module name
147
+ module = toLower(target);
148
+ }
149
+ } else {
150
+ module = toLower(target);
151
+ }
152
+ } else {
153
+ // Interactive: pick module, then optionally entity
154
+ console.log('');
155
+ const menusDir = path.join(cwd, 'src', 'frontend', 'menus');
156
+ const menuFiles = (await fs.readdir(menusDir)).filter(f => f.endsWith('.menu.json'));
157
+ const modules = menuFiles
158
+ .filter(f => !f.includes('.') || f.split('.').length === 2)
159
+ .map(f => f.replace('.menu.json', ''));
160
+
161
+ module = await textInput('Module name (e.g. rh):');
162
+ const ent = await textInput('Entity name (leave blank for module-level menu):');
163
+ if (ent) { entity = ent; isViewMenu = true; }
164
+ }
165
+
166
+ const menusDir = path.join(cwd, 'src', 'frontend', 'menus');
167
+
168
+ // ── View-level menu ───────────────────────────────────────────────────────
169
+ if (isViewMenu) {
170
+ const menuFile = path.join(menusDir, `${module}.${entity}.menu.json`);
171
+ const existing = (await exists(menuFile)) ? await fs.readJson(menuFile) : null;
172
+ await editViewMenu(menuFile, existing);
173
+ return;
174
+ }
175
+
176
+ // ── Module-level menu ─────────────────────────────────────────────────────
177
+ const menuFile = path.join(menusDir, `${module}.menu.json`);
178
+ let existing = { module, label: toPascal(module), hMenu: { style: 'classic', items: [] }, vMenu: { items: [] } };
179
+ if (await exists(menuFile)) {
180
+ existing = await fs.readJson(menuFile);
181
+ }
182
+
183
+ // Pass 1 — hMenu
184
+ console.log('');
185
+ console.log(chalk.bold.white(` ${toPascal(module)} — hMenu`));
186
+ const hRows = flattenItems(existing.hMenu?.items || []);
187
+ if (!hRows.length) {
188
+ hRows.push({ label: `${toPascal(module)} Home`, route: `/${module}`, icon: '', parent: '', permission: '' });
189
+ }
190
+
191
+ const updatedH = await fieldsTable(
192
+ hRows,
193
+ ['label', 'route', 'icon', 'parent', 'permission'],
194
+ { label: 16, route: 20, icon: 6, parent: 14, permission: 20 },
195
+ 'hMenu (parent = dropdown group label, empty = top level enter done)'
196
+ );
197
+
198
+ // Pass 2 — vMenu
199
+ console.log('');
200
+ console.log(chalk.bold.white(` ${toPascal(module)} — vMenu`));
201
+ const vRows = flattenItems(existing.vMenu?.items || []);
202
+ if (!vRows.length) {
203
+ vRows.push({ label: toPascal(module), route: `/${module}`, icon: '', parent: '', permission: '' });
204
+ }
205
+
206
+ const updatedV = await fieldsTable(
207
+ vRows,
208
+ ['label', 'route', 'icon', 'parent', 'permission'],
209
+ { label: 16, route: 20, icon: 6, parent: 14, permission: 20 },
210
+ 'vMenu (parent = collapsible group label, empty = top level enter done)'
211
+ );
212
+
213
+ // Also check hMenu style
214
+ console.log('');
215
+ console.log(chalk.bold.white(' hMenu style'));
216
+ const style = await radioLine('', [
217
+ { label: 'Classic (dropdown)', value: 'classic' },
218
+ { label: 'Ribbon (Office)', value: 'ribbon' },
219
+ ], existing.hMenu?.style === 'ribbon' ? 1 : 0);
220
+
221
+ const updated = {
222
+ module,
223
+ label: toPascal(module),
224
+ hMenu: { style, items: nestItems(updatedH) },
225
+ vMenu: { items: nestItems(updatedV) },
226
+ };
227
+
228
+ await writeJson(menuFile, updated);
229
+ log.success(`${module}.menu.json updated.`);
230
+ }