@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,330 @@
1
+ /**
2
+ * TUI Engine
3
+ * Keyboard-driven interactive terminal UI components.
4
+ * Uses raw stdin to capture arrow keys, space, enter etc.
5
+ */
6
+
7
+ import readline from 'readline';
8
+ import chalk from 'chalk';
9
+
10
+ // ─── Raw input helpers ────────────────────────────────────────────────────────
11
+
12
+ export function enableRawInput() {
13
+ if (process.stdin.isTTY) {
14
+ process.stdin.setRawMode(true);
15
+ }
16
+ process.stdin.resume();
17
+ process.stdin.setEncoding('utf8');
18
+ }
19
+
20
+ export function disableRawInput() {
21
+ if (process.stdin.isTTY) {
22
+ process.stdin.setRawMode(false);
23
+ }
24
+ process.stdin.pause();
25
+ }
26
+
27
+ export function clearLines(n) {
28
+ for (let i = 0; i < n; i++) {
29
+ process.stdout.write('\x1B[1A\x1B[2K');
30
+ }
31
+ }
32
+
33
+ export function hideCursor() { process.stdout.write('\x1B[?25l'); }
34
+ export function showCursor() { process.stdout.write('\x1B[?25h'); }
35
+
36
+ // ─── Key constants ────────────────────────────────────────────────────────────
37
+
38
+ export const KEYS = {
39
+ UP: '\x1B[A',
40
+ DOWN: '\x1B[B',
41
+ RIGHT: '\x1B[C',
42
+ LEFT: '\x1B[D',
43
+ ENTER: '\r',
44
+ SPACE: ' ',
45
+ PLUS: '+',
46
+ MINUS: '-',
47
+ ESC: '\x1B',
48
+ CTRL_C: '\x03',
49
+ BACKSPACE: '\x7F',
50
+ };
51
+
52
+ // ─── Simple text input ────────────────────────────────────────────────────────
53
+
54
+ export async function textInput(prompt, defaultVal = '') {
55
+ return new Promise((resolve) => {
56
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
57
+ const hint = defaultVal ? chalk.dim(` (${defaultVal})`) : '';
58
+ rl.question(chalk.cyan(' ? ') + prompt + hint + ' ', (answer) => {
59
+ rl.close();
60
+ resolve(answer.trim() || defaultVal);
61
+ });
62
+ });
63
+ }
64
+
65
+ // ─── Radio selector ───────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Horizontal radio — one choice from options
69
+ * Returns selected value
70
+ */
71
+ export async function radioLine(prompt, options, defaultIdx = 0) {
72
+ let selected = defaultIdx;
73
+
74
+ const render = () => {
75
+ process.stdout.clearLine?.(0);
76
+ process.stdout.cursorTo?.(0);
77
+ const opts = options.map((o, i) =>
78
+ i === selected
79
+ ? chalk.green(`◉ ${o.label}`)
80
+ : chalk.dim(`○ ${o.label}`)
81
+ ).join(' ');
82
+ process.stdout.write(` ${chalk.cyan(prompt)} ${opts}`);
83
+ };
84
+
85
+ return new Promise((resolve) => {
86
+ hideCursor();
87
+ enableRawInput();
88
+ console.log('');
89
+ render();
90
+
91
+ const onKey = (key) => {
92
+ if (key === KEYS.CTRL_C) { showCursor(); disableRawInput(); process.exit(0); }
93
+ if (key === KEYS.LEFT) { selected = (selected - 1 + options.length) % options.length; render(); }
94
+ if (key === KEYS.RIGHT) { selected = (selected + 1) % options.length; render(); }
95
+ if (key === KEYS.ENTER) {
96
+ process.stdout.write('\n');
97
+ process.stdin.removeListener('data', onKey);
98
+ showCursor();
99
+ disableRawInput();
100
+ resolve(options[selected].value);
101
+ }
102
+ };
103
+ process.stdin.on('data', onKey);
104
+ });
105
+ }
106
+
107
+ // ─── Options toggles ──────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * One-line toggleable options
111
+ * Returns object { [key]: boolean }
112
+ */
113
+ export async function optionsLine(options) {
114
+ // options = [{ key, label, default }]
115
+ const state = {};
116
+ options.forEach(o => { state[o.key] = o.default !== false; });
117
+ let cursor = 0;
118
+
119
+ const render = (lines = 1) => {
120
+ clearLines(lines);
121
+ const rendered = options.map((o, i) => {
122
+ const on = state[o.key];
123
+ const box = on ? chalk.green('◼') : chalk.dim('◻');
124
+ const label = i === cursor ? chalk.bold.white(o.label) : (on ? chalk.white(o.label) : chalk.dim(o.label));
125
+ return `${box} ${label}`;
126
+ }).join(' ');
127
+ console.log(' ' + rendered);
128
+ console.log(chalk.dim(' ← → navigate space toggle enter confirm'));
129
+ };
130
+
131
+ return new Promise((resolve) => {
132
+ hideCursor();
133
+ enableRawInput();
134
+ console.log('');
135
+ const rendered = options.map((o) => {
136
+ const on = state[o.key];
137
+ return `${on ? chalk.green('◼') : chalk.dim('◻')} ${on ? chalk.white(o.label) : chalk.dim(o.label)}`;
138
+ }).join(' ');
139
+ console.log(' ' + rendered);
140
+ console.log(chalk.dim(' ← → navigate space toggle enter confirm'));
141
+
142
+ const onKey = (key) => {
143
+ if (key === KEYS.CTRL_C) { showCursor(); disableRawInput(); process.exit(0); }
144
+ if (key === KEYS.LEFT) { cursor = (cursor - 1 + options.length) % options.length; render(2); }
145
+ if (key === KEYS.RIGHT) { cursor = (cursor + 1) % options.length; render(2); }
146
+ if (key === KEYS.SPACE) { state[options[cursor].key] = !state[options[cursor].key]; render(2); }
147
+ if (key === KEYS.ENTER) {
148
+ process.stdin.removeListener('data', onKey);
149
+ showCursor();
150
+ disableRawInput();
151
+ resolve({ ...state });
152
+ }
153
+ };
154
+ process.stdin.on('data', onKey);
155
+ });
156
+ }
157
+
158
+ // ─── Interactive table ────────────────────────────────────────────────────────
159
+
160
+ const COL_WIDTHS = {
161
+ field: 18,
162
+ type: 10,
163
+ values: 14,
164
+ pk: 4,
165
+ fk: 4,
166
+ group: 14,
167
+ };
168
+
169
+ const FIELD_TYPES = ['varchar', 'int', 'decimal', 'date', 'datetime', 'bit', 'text', 'float', 'enum'];
170
+
171
+ const AUDIT_COL_WIDTHS = { field: 20, type: 10 };
172
+
173
+ function pad(str, len) {
174
+ const s = String(str ?? '');
175
+ return s.length >= len ? s.slice(0, len - 1) + ' ' : s + ' '.repeat(len - s.length);
176
+ }
177
+
178
+ function renderTableHeader(cols, widths) {
179
+ const header = cols.map(c => chalk.bold.cyan(pad(c, widths[c] || 12))).join(chalk.dim('│'));
180
+ const divider = cols.map(c => '─'.repeat(widths[c] || 12)).join('┼');
181
+ return ` ┌${'─'.repeat(divider.length)}┐\n │${header}│\n ├${divider}┤`;
182
+ }
183
+
184
+ function renderFieldRow(row, cols, widths, isActive, activeCol) {
185
+ const cells = cols.map((col, ci) => {
186
+ let val = row[col] ?? '';
187
+ if (col === 'pk' || col === 'fk') val = val ? chalk.green('◼') : chalk.dim('◻');
188
+ const isThisCell = isActive && ci === activeCol;
189
+ const cell = pad(val, widths[col] || 12);
190
+ return isThisCell ? chalk.bgBlue.white(cell) : (isActive ? chalk.white(cell) : chalk.dim(cell));
191
+ }).join(chalk.dim('│'));
192
+ return ` │${cells}│`;
193
+ }
194
+
195
+ /**
196
+ * Interactive fields table
197
+ * Returns array of field objects
198
+ */
199
+ export async function fieldsTable(initialRows, cols, widths, title) {
200
+ const rows = initialRows.map(r => ({ ...r }));
201
+ let rowIdx = 0;
202
+ let colIdx = 0;
203
+ let editBuffer = null; // currently editing text cell
204
+ let typeMenuOpen = false;
205
+ let typeMenuIdx = 0;
206
+
207
+ const toggleCols = cols.filter(c => c === 'pk' || c === 'fk');
208
+ const textCols = cols.filter(c => c !== 'pk' && c !== 'fk');
209
+
210
+ function totalLines() {
211
+ return rows.length + 4; // header(3) + footer(1)
212
+ }
213
+
214
+ function render(initial = false) {
215
+ if (!initial) clearLines(totalLines() + (typeMenuOpen ? FIELD_TYPES.length + 2 : 0));
216
+
217
+ console.log('\n ' + chalk.bold.white(title));
218
+ console.log(renderTableHeader(cols, widths));
219
+
220
+ rows.forEach((row, ri) => {
221
+ console.log(renderFieldRow(row, cols, widths, ri === rowIdx, colIdx));
222
+ });
223
+
224
+ console.log(` └${'─'.repeat(cols.map(c => widths[c] || 12).reduce((a, b) => a + b, 0) + cols.length - 1)}┘`);
225
+ console.log(chalk.dim(' ← → cols ↑ ↓ rows + add row - del row space toggle pk/fk enter done'));
226
+
227
+ if (typeMenuOpen) {
228
+ console.log('');
229
+ FIELD_TYPES.forEach((t, i) => {
230
+ console.log(i === typeMenuIdx ? chalk.green(` ▶ ${t}`) : chalk.dim(` ${t}`));
231
+ });
232
+ }
233
+ }
234
+
235
+ return new Promise((resolve) => {
236
+ hideCursor();
237
+ enableRawInput();
238
+ render(true);
239
+
240
+ const onKey = (key) => {
241
+ if (key === KEYS.CTRL_C) { showCursor(); disableRawInput(); process.exit(0); }
242
+
243
+ // Type menu open
244
+ if (typeMenuOpen) {
245
+ if (key === KEYS.UP) { typeMenuIdx = (typeMenuIdx - 1 + FIELD_TYPES.length) % FIELD_TYPES.length; render(); return; }
246
+ if (key === KEYS.DOWN) { typeMenuIdx = (typeMenuIdx + 1) % FIELD_TYPES.length; render(); return; }
247
+ if (key === KEYS.ENTER) {
248
+ rows[rowIdx].type = FIELD_TYPES[typeMenuIdx];
249
+ typeMenuOpen = false;
250
+ render();
251
+ return;
252
+ }
253
+ if (key === KEYS.ESC) { typeMenuOpen = false; render(); return; }
254
+ return;
255
+ }
256
+
257
+ // Navigation
258
+ if (key === KEYS.UP) { rowIdx = Math.max(0, rowIdx - 1); editBuffer = null; render(); return; }
259
+ if (key === KEYS.DOWN) { rowIdx = Math.min(rows.length - 1, rowIdx + 1); editBuffer = null; render(); return; }
260
+ if (key === KEYS.LEFT) { colIdx = Math.max(0, colIdx - 1); editBuffer = null; render(); return; }
261
+ if (key === KEYS.RIGHT) { colIdx = Math.min(cols.length - 1, colIdx + 1); editBuffer = null; render(); return; }
262
+
263
+ // Add / remove rows
264
+ if (key === KEYS.PLUS) {
265
+ const newRow = {};
266
+ cols.forEach(c => { newRow[c] = c === 'pk' || c === 'fk' ? false : ''; });
267
+ rows.splice(rowIdx + 1, 0, newRow);
268
+ rowIdx++;
269
+ render();
270
+ return;
271
+ }
272
+ if (key === KEYS.MINUS) {
273
+ if (rows.length > 1) { rows.splice(rowIdx, 1); rowIdx = Math.min(rowIdx, rows.length - 1); }
274
+ render();
275
+ return;
276
+ }
277
+
278
+ // Toggle pk/fk
279
+ if (key === KEYS.SPACE) {
280
+ const col = cols[colIdx];
281
+ if (col === 'pk' || col === 'fk') {
282
+ rows[rowIdx][col] = !rows[rowIdx][col];
283
+ // Only one PK allowed
284
+ if (col === 'pk' && rows[rowIdx][col]) {
285
+ rows.forEach((r, i) => { if (i !== rowIdx) r.pk = false; });
286
+ }
287
+ render();
288
+ return;
289
+ }
290
+ // Open type menu on type column
291
+ if (col === 'type') {
292
+ typeMenuOpen = true;
293
+ typeMenuIdx = FIELD_TYPES.indexOf(rows[rowIdx].type || 'varchar');
294
+ if (typeMenuIdx < 0) typeMenuIdx = 0;
295
+ render();
296
+ return;
297
+ }
298
+ }
299
+
300
+ // Done
301
+ if (key === KEYS.ENTER) {
302
+ const col = cols[colIdx];
303
+ if (col === 'type') { typeMenuOpen = true; typeMenuIdx = 0; render(); return; }
304
+ process.stdin.removeListener('data', onKey);
305
+ showCursor();
306
+ disableRawInput();
307
+ resolve(rows);
308
+ return;
309
+ }
310
+
311
+ // Text editing for current cell
312
+ const col = cols[colIdx];
313
+ if (col === 'pk' || col === 'fk') return;
314
+
315
+ if (key === KEYS.BACKSPACE) {
316
+ rows[rowIdx][col] = String(rows[rowIdx][col] || '').slice(0, -1);
317
+ render();
318
+ return;
319
+ }
320
+
321
+ // Printable character
322
+ if (key.length === 1 && key >= ' ') {
323
+ rows[rowIdx][col] = (rows[rowIdx][col] || '') + key;
324
+ render();
325
+ }
326
+ };
327
+
328
+ process.stdin.on('data', onKey);
329
+ });
330
+ }
@@ -0,0 +1,125 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+
5
+ export async function writeFile(filePath, content, { dryRun = false } = {}) {
6
+ if (dryRun) {
7
+ console.log(chalk.cyan(' [dry-run]'), filePath);
8
+ return;
9
+ }
10
+ await fs.ensureDir(path.dirname(filePath));
11
+ await fs.writeFile(filePath, content, 'utf8');
12
+ console.log(chalk.green(' created '), chalk.dim(filePath));
13
+ }
14
+
15
+ export async function writeJson(filePath, data, opts = {}) {
16
+ await writeFile(filePath, JSON.stringify(data, null, 2) + '\n', opts);
17
+ }
18
+
19
+ export async function readJson(filePath) {
20
+ return fs.readJson(filePath);
21
+ }
22
+
23
+ export async function exists(p) {
24
+ return fs.pathExists(p);
25
+ }
26
+
27
+ export async function readDir(p) {
28
+ if (!(await exists(p))) return [];
29
+ const entries = await fs.readdir(p, { withFileTypes: true });
30
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
31
+ }
32
+
33
+ export function assertProjectRoot(cwd = process.cwd()) {
34
+ const marker = path.join(cwd, 'src', 'backend', 'engine');
35
+ if (!fs.existsSync(marker)) {
36
+ console.error(chalk.red('✖ Not inside a generated eapp project.'));
37
+ console.error(chalk.dim(' Could not find src/backend/engine/ — are you in the right directory?'));
38
+ process.exit(1);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Read all ipc.json files to build entity registry
44
+ * Returns Map<"module.entity", { module, entity, schema, fields, pkField }>
45
+ */
46
+ export async function loadEntityRegistry(cwd = process.cwd()) {
47
+ const registry = new Map();
48
+ const backendRoot = path.join(cwd, 'src', 'backend');
49
+
50
+ async function scanDir(dir, module) {
51
+ if (!(await exists(dir))) return;
52
+ const entries = await fs.readdir(dir, { withFileTypes: true });
53
+ for (const entry of entries) {
54
+ if (!entry.isDirectory()) continue;
55
+ const entityDir = path.join(dir, entry.name);
56
+ const ipcFile = path.join(entityDir, 'ipc.json');
57
+ const metaFile = path.join(entityDir, 'entity.meta.json');
58
+ if (await exists(ipcFile)) {
59
+ const ipc = await readJson(ipcFile);
60
+ const meta = (await exists(metaFile)) ? await readJson(metaFile) : {};
61
+ const entity = entry.name.toLowerCase();
62
+ registry.set(`${module}.${entity}`, {
63
+ module,
64
+ entity,
65
+ schema: meta.schema || (module === 'shared' ? 'dbo' : module),
66
+ fields: meta.fields || [],
67
+ auditFields: meta.auditFields || [],
68
+ pkField: meta.pkField || 'id',
69
+ slaves: meta.slaves || [],
70
+ });
71
+ }
72
+ }
73
+ }
74
+
75
+ // Scan shared
76
+ await scanDir(path.join(backendRoot, 'shared'), 'shared');
77
+
78
+ // Scan modules
79
+ const modulesDir = path.join(backendRoot, 'modules');
80
+ if (await exists(modulesDir)) {
81
+ const modules = await readDir(modulesDir);
82
+ for (const mod of modules) {
83
+ await scanDir(path.join(modulesDir, mod), mod.toLowerCase());
84
+ }
85
+ }
86
+
87
+ return registry;
88
+ }
89
+
90
+ /**
91
+ * Append or update a menu entry in a menu.json file
92
+ */
93
+ export async function upsertMenuItem(menuFilePath, { module, entity, route, hMenu, vMenu }) {
94
+ let menu = { module, label: module, hMenu: { style: 'classic', items: [] }, vMenu: { items: [] } };
95
+ if (await exists(menuFilePath)) {
96
+ menu = await readJson(menuFilePath);
97
+ }
98
+
99
+ // Normalise: support both old array format and new { items: [] } format
100
+ if (Array.isArray(menu.hMenu)) menu.hMenu = { style: 'classic', items: menu.hMenu };
101
+ if (Array.isArray(menu.vMenu)) menu.vMenu = { items: menu.vMenu };
102
+ if (!menu.hMenu) menu.hMenu = { style: 'classic', items: [] };
103
+ if (!menu.vMenu) menu.vMenu = { items: [] };
104
+ if (!Array.isArray(menu.hMenu.items)) menu.hMenu.items = [];
105
+ if (!Array.isArray(menu.vMenu.items)) menu.vMenu.items = [];
106
+
107
+ const item = {
108
+ label: entity.charAt(0).toUpperCase() + entity.slice(1),
109
+ route,
110
+ icon: '',
111
+ permission: `${module}.${entity}.view`,
112
+ };
113
+
114
+ const upsert = (arr, item) => {
115
+ const idx = arr.findIndex(i => i.route === item.route);
116
+ if (idx >= 0) arr[idx] = item;
117
+ else arr.push(item);
118
+ return arr;
119
+ };
120
+
121
+ if (hMenu) menu.hMenu.items = upsert(menu.hMenu.items, item);
122
+ if (vMenu) menu.vMenu.items = upsert(menu.vMenu.items, item);
123
+
124
+ await writeJson(menuFilePath, menu);
125
+ }
@@ -0,0 +1,11 @@
1
+ import chalk from 'chalk';
2
+
3
+ export const log = {
4
+ info: (...a) => console.log(chalk.blue('ℹ'), ...a),
5
+ success: (...a) => console.log(chalk.green('✔'), ...a),
6
+ warn: (...a) => console.log(chalk.yellow('⚠'), ...a),
7
+ error: (...a) => console.error(chalk.red('✖'), ...a),
8
+ title: (s) => console.log('\n' + chalk.bold.white(s)),
9
+ dim: (...a) => console.log(chalk.dim(...a)),
10
+ danger: (...a) => console.log(chalk.red('⚠'), chalk.red(...a)),
11
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Naming utilities
3
+ */
4
+
5
+ export const toUpper = (s) => s.toUpperCase();
6
+ export const toLower = (s) => s.toLowerCase();
7
+
8
+ export const toPascal = (s) =>
9
+ s.split(/[-_\s]+/)
10
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
11
+ .join('');
12
+
13
+ export const isValidName = (s) => /^[a-zA-Z0-9_-]+$/.test(s);
14
+
15
+ /**
16
+ * Parse "module/entity.schema" format
17
+ *
18
+ * /employee → { module: 'shared', entity: 'employee', schema: 'dbo' }
19
+ * rh/employee → { module: 'rh', entity: 'employee', schema: 'rh' }
20
+ * rh/employee.dbo → { module: 'rh', entity: 'employee', schema: 'dbo' }
21
+ * rh/employee.hr → { module: 'rh', entity: 'employee', schema: 'hr' }
22
+ * rh/employee. → { module: 'rh', entity: 'employee', schema: 'rh' } (dot alone = module)
23
+ * employee → { module: 'shared', entity: 'employee', schema: 'dbo' } (no slash = shared)
24
+ */
25
+ export function parseEntityPath(input) {
26
+ if (!input) throw new Error('No entity path provided.');
27
+
28
+ let raw = input.trim();
29
+
30
+ // Handle old dot notation for backwards compat: rh.employee → rh/employee
31
+ if (!raw.includes('/') && raw.includes('.') && raw.split('.').length === 2) {
32
+ const [mod, ent] = raw.split('.');
33
+ raw = `${mod}/${ent}`;
34
+ }
35
+
36
+ // No slash at all → shared
37
+ if (!raw.includes('/')) {
38
+ return { module: 'shared', entity: toLower(raw), schema: 'dbo' };
39
+ }
40
+
41
+ // Split on /
42
+ const slashIdx = raw.indexOf('/');
43
+ const modulePart = raw.slice(0, slashIdx); // may be empty → shared
44
+ const rest = raw.slice(slashIdx + 1); // entity.schema or entity
45
+
46
+ const module = modulePart === '' ? 'shared' : toLower(modulePart);
47
+
48
+ // Split entity and schema on .
49
+ const dotIdx = rest.indexOf('.');
50
+ let entity, schema;
51
+
52
+ if (dotIdx === -1) {
53
+ // No dot → schema = module (or dbo for shared)
54
+ entity = toLower(rest);
55
+ schema = module === 'shared' ? 'dbo' : module;
56
+ } else {
57
+ entity = toLower(rest.slice(0, dotIdx));
58
+ const schemaPart = rest.slice(dotIdx + 1);
59
+ if (schemaPart === '') {
60
+ // Trailing dot → schema = module
61
+ schema = module === 'shared' ? 'dbo' : module;
62
+ } else {
63
+ schema = toLower(schemaPart);
64
+ }
65
+ }
66
+
67
+ if (!isValidName(entity)) {
68
+ throw new Error(`Invalid entity name "${entity}". Use alphanumeric, hyphens, underscores only.`);
69
+ }
70
+ if (module !== 'shared' && !isValidName(module)) {
71
+ throw new Error(`Invalid module name "${module}".`);
72
+ }
73
+
74
+ return { module, entity, schema };
75
+ }
76
+
77
+ /**
78
+ * Derive IPC route
79
+ * shared + client → shared.CLIENT
80
+ * rh + employee → modules.RH.EMPLOYEE
81
+ */
82
+ export function ipcRoute(module, entity) {
83
+ if (module === 'shared') return `shared.${toUpper(entity)}`;
84
+ return `modules.${toUpper(module)}.${toUpper(entity)}`;
85
+ }
86
+
87
+ /**
88
+ * Derive the backend directory path for an entity
89
+ */
90
+ export function backendEntityDir(cwd, module, entity) {
91
+ if (module === 'shared') {
92
+ return `${cwd}/src/backend/shared/${toUpper(entity)}`;
93
+ }
94
+ return `${cwd}/src/backend/modules/${toUpper(module)}/${toUpper(entity)}`;
95
+ }