langaro-api 1.0.3 → 1.0.4

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.
@@ -3,7 +3,6 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
- // Load config from langaro-api.config.js at cwd, or use defaults
7
6
  function loadConfig() {
8
7
  const configPath = path.resolve(process.cwd(), 'langaro-api.config.js');
9
8
  if (fs.existsSync(configPath)) {
@@ -13,63 +12,170 @@ function loadConfig() {
13
12
  return {};
14
13
  }
15
14
 
16
- // ── Commands ──
17
-
18
- const command = process.argv[2];
19
-
20
- if (command === 'init') {
21
- // Project scaffolding
22
- const runInit = require('../lib/cli/init');
23
- runInit();
24
- } else if (command === 'migrate') {
25
- // Migrate existing project to use langaro-api loaders
26
- const runMigrate = require('../lib/cli/migrate');
27
- runMigrate();
28
- } else if (command === 'new') {
29
- // Interactive resource generator
30
- const runNew = require('../lib/cli/new');
15
+ // ── Interactive menu (arrow keys) ──
16
+
17
+ function singleSelect(question, options) {
18
+ return new Promise((resolve) => {
19
+ let cursor = 0;
20
+ // Count how many terminal lines the question occupies (including trailing newlines)
21
+ const questionLines = question.split('\n').length;
22
+ const totalLines = questionLines + options.length;
23
+
24
+ function render() {
25
+ process.stdout.write(`\x1b[${totalLines}A\x1b[J`);
26
+ console.log(question);
27
+ options.forEach((opt, i) => {
28
+ const pointer = i === cursor ? '\x1b[36m> \x1b[0m' : ' ';
29
+ const label = i === cursor ? `\x1b[36m${opt.label}\x1b[0m` : opt.label;
30
+ const desc = opt.desc ? `\x1b[90m — ${opt.desc}\x1b[0m` : '';
31
+ console.log(`${pointer}${label}${desc}`);
32
+ });
33
+ }
34
+
35
+ console.log(question);
36
+ options.forEach((opt, i) => {
37
+ const pointer = i === cursor ? '\x1b[36m> \x1b[0m' : ' ';
38
+ const label = i === cursor ? `\x1b[36m${opt.label}\x1b[0m` : opt.label;
39
+ const desc = opt.desc ? `\x1b[90m — ${opt.desc}\x1b[0m` : '';
40
+ console.log(`${pointer}${label}${desc}`);
41
+ });
42
+
43
+ process.stdin.setRawMode(true);
44
+ process.stdin.resume();
45
+
46
+ const onKeypress = (key) => {
47
+ if (key[0] === 27 && key[1] === 91 && key[2] === 65) {
48
+ cursor = (cursor - 1 + options.length) % options.length;
49
+ render();
50
+ } else if (key[0] === 27 && key[1] === 91 && key[2] === 66) {
51
+ cursor = (cursor + 1) % options.length;
52
+ render();
53
+ } else if (key[0] === 13 || key[0] === 10) {
54
+ process.stdin.setRawMode(false);
55
+ process.stdin.removeListener('data', onKeypress);
56
+ resolve(options[cursor].value);
57
+ } else if (key[0] === 113 || key[0] === 3) {
58
+ process.stdin.setRawMode(false);
59
+ process.stdin.removeListener('data', onKeypress);
60
+ process.exit(0);
61
+ }
62
+ };
63
+
64
+ process.stdin.on('data', onKeypress);
65
+ });
66
+ }
67
+
68
+ function confirm(question) {
69
+ return singleSelect(question, [
70
+ { label: 'Yes, proceed' , value: true },
71
+ { label: 'No, cancel', value: false },
72
+ ]);
73
+ }
74
+
75
+ // ── Command runners ──
76
+
77
+ function runGenerateTypes() {
78
+ const { generateTypes } = require('../lib/index');
31
79
  const config = loadConfig();
32
- runNew(config);
33
- } else {
34
- // Default: generate types
80
+ generateTypes(config);
81
+ console.log('[langaro-api] Types + JSDoc annotations generated.');
82
+ }
83
+
84
+ function runWatch() {
35
85
  const { generateTypes, getWatchDirs } = require('../lib/index');
36
86
 
37
- function run() {
87
+ function generate() {
38
88
  const config = loadConfig();
39
89
  generateTypes(config);
40
90
  console.log('[langaro-api] Types + JSDoc annotations generated.');
41
91
  }
42
92
 
43
- run();
44
-
45
- // Watch mode
46
- if (process.argv.includes('--watch')) {
47
- const config = loadConfig();
48
- const dirs = getWatchDirs(config);
93
+ generate();
49
94
 
50
- let debounceTimer = null;
95
+ const config = loadConfig();
96
+ const dirs = getWatchDirs(config);
97
+ let debounceTimer = null;
51
98
 
52
- function onChange() {
53
- if (debounceTimer) clearTimeout(debounceTimer);
54
- debounceTimer = setTimeout(() => {
55
- try {
56
- run();
57
- } catch (err) {
58
- console.error('[langaro-api] Error:', err.message);
99
+ dirs.forEach((dir) => {
100
+ try {
101
+ fs.watch(dir, { recursive: true }, (eventType, filename) => {
102
+ if (filename && filename.endsWith('.js')) {
103
+ if (debounceTimer) clearTimeout(debounceTimer);
104
+ debounceTimer = setTimeout(() => {
105
+ try { generate(); } catch (err) { console.error('[langaro-api] Error:', err.message); }
106
+ }, 300);
59
107
  }
60
- }, 300);
108
+ });
109
+ } catch (err) {
110
+ console.warn(`[langaro-api] Could not watch ${dir}: ${err.message}`);
61
111
  }
112
+ });
62
113
 
63
- dirs.forEach((dir) => {
64
- try {
65
- fs.watch(dir, { recursive: true }, (eventType, filename) => {
66
- if (filename && filename.endsWith('.js')) onChange();
67
- });
68
- } catch (err) {
69
- console.warn(`[langaro-api] Could not watch ${dir}: ${err.message}`);
70
- }
71
- });
114
+ console.log(`[langaro-api] Watching ${dirs.length} directories for changes...`);
115
+ }
116
+
117
+ async function runInit() {
118
+ const ok = await confirm('\n\x1b[33mThis will create project files in the current directory.\x1b[0m Are you sure?\n');
119
+ if (!ok) { console.log('\nAborted.\n'); process.exit(0); }
120
+ const init = require('../lib/cli/init');
121
+ await init();
122
+ }
123
+
124
+ async function runMigrate() {
125
+ const ok = await confirm('\n\x1b[33mThis will replace your index.js files with langaro-api loaders.\x1b[0m Are you sure?\n');
126
+ if (!ok) { console.log('\nAborted.\n'); process.exit(0); }
127
+ const migrate = require('../lib/cli/migrate');
128
+ migrate();
129
+ }
130
+
131
+ async function runNew() {
132
+ const newCmd = require('../lib/cli/new');
133
+ const config = loadConfig();
134
+ await newCmd(config);
135
+ }
136
+
137
+ // ── Main ──
72
138
 
73
- console.log(`[langaro-api] Watching ${dirs.length} directories for changes...`);
139
+ async function main() {
140
+ const command = process.argv[2];
141
+
142
+ // Direct commands (for scripts, CI, --watch)
143
+ if (command === 'init') return runInit();
144
+ if (command === 'migrate') return runMigrate();
145
+ if (command === 'new') return runNew();
146
+ if (command === '--watch') return runWatch();
147
+ if (command === 'generate' || command === 'types') {
148
+ runGenerateTypes();
149
+ return process.exit(0);
150
+ }
151
+
152
+ // No args + non-TTY (e.g. piped, CI): just generate types
153
+ if (!process.stdin.isTTY) {
154
+ runGenerateTypes();
155
+ return process.exit(0);
74
156
  }
157
+
158
+ // No args + TTY: interactive menu
159
+ console.log('\n\x1b[36m langaro-api\x1b[0m\n');
160
+
161
+ const choice = await singleSelect(' What would you like to do?\n', [
162
+ { label: 'Generate types', value: 'types', desc: 'Generate .d.ts files and inject JSDoc annotations' },
163
+ { label: 'New resource', value: 'new', desc: 'Scaffold a new controller, service, model, or route' },
164
+ { label: 'Migrate project', value: 'migrate', desc: 'Replace index.js files with langaro-api loaders' },
165
+ { label: 'Init new project', value: 'init', desc: 'Scaffold a full project from scratch' },
166
+ ]);
167
+
168
+ console.log('');
169
+
170
+ if (choice === 'types') { runGenerateTypes(); process.exit(0); }
171
+ if (choice === 'new') return runNew();
172
+ if (choice === 'migrate') return runMigrate();
173
+ if (choice === 'init') return runInit();
174
+
175
+ return undefined;
75
176
  }
177
+
178
+ main().catch((err) => {
179
+ console.error(err);
180
+ process.exit(1);
181
+ });
package/lib/cli/init.js CHANGED
@@ -15,6 +15,10 @@ function ask(rl, question, defaultVal) {
15
15
 
16
16
  function writeFile(root, filePath, content) {
17
17
  const full = path.join(root, filePath);
18
+ if (fs.existsSync(full)) {
19
+ console.log(` \x1b[33mskip\x1b[0m ${filePath} (already exists)`);
20
+ return;
21
+ }
18
22
  fs.mkdirSync(path.dirname(full), { recursive: true });
19
23
  fs.writeFileSync(full, content);
20
24
  console.log(` \x1b[32mcreated\x1b[0m ${filePath}`);
package/lib/cli/new.js CHANGED
@@ -83,59 +83,184 @@ function ask(rl, question) {
83
83
  });
84
84
  }
85
85
 
86
- function multiSelect(rl, question, options) {
86
+ function singleSelect(question, allOptions) {
87
87
  return new Promise((resolve) => {
88
- const selected = new Set(options.map((_, i) => i)); // all selected by default
88
+ const searchable = allOptions.length > 10;
89
+ let filter = '';
90
+ let filtered = allOptions;
89
91
  let cursor = 0;
92
+ let lastRenderedLines = 0;
93
+
94
+ // Compute how many items we can show based on terminal height
95
+ // Reserve lines for: question + filter bar + "more above" + "more below" + 1 buffer
96
+ function getMaxVisible() {
97
+ const termRows = process.stdout.rows || 24;
98
+ const overhead = 1 + (searchable ? 1 : 0) + 2 + 1; // question + filter + above/below + buffer
99
+ return Math.max(3, Math.min(10, termRows - overhead));
100
+ }
101
+
102
+ function getVisible() {
103
+ const maxVisible = getMaxVisible();
104
+ if (filtered.length <= maxVisible) return { items: filtered, startIdx: 0 };
105
+ let start = cursor - Math.floor(maxVisible / 2);
106
+ if (start < 0) start = 0;
107
+ if (start + maxVisible > filtered.length) start = filtered.length - maxVisible;
108
+ return { items: filtered.slice(start, start + maxVisible), startIdx: start };
109
+ }
110
+
111
+ function draw() {
112
+ if (lastRenderedLines > 0) {
113
+ // Cap move-up to terminal height to avoid glitches when output scrolled off screen
114
+ const moveUp = Math.min(lastRenderedLines, (process.stdout.rows || 24) - 1);
115
+ process.stdout.write(`\x1b[${moveUp}A\x1b[J`);
116
+ }
117
+
118
+ const lines = [];
119
+ lines.push(question.trimEnd());
120
+
121
+ if (searchable) {
122
+ lines.push(` \x1b[90mType to filter:\x1b[0m ${filter}\x1b[90m|\x1b[0m`);
123
+ }
124
+
125
+ const { items, startIdx } = getVisible();
126
+
127
+ if (startIdx > 0) lines.push(` \x1b[90m ... ${startIdx} more above\x1b[0m`);
128
+
129
+ items.forEach((opt, i) => {
130
+ const realIdx = startIdx + i;
131
+ const pointer = realIdx === cursor ? '\x1b[36m> \x1b[0m' : ' ';
132
+ const label = realIdx === cursor ? `\x1b[36m${opt.label}\x1b[0m` : opt.label;
133
+ const desc = opt.desc ? `\x1b[90m — ${opt.desc}\x1b[0m` : '';
134
+ lines.push(`${pointer}${label}${desc}`);
135
+ });
136
+
137
+ const endIdx = startIdx + items.length;
138
+ if (endIdx < filtered.length) lines.push(` \x1b[90m ... ${filtered.length - endIdx} more below\x1b[0m`);
139
+
140
+ if (filtered.length === 0) lines.push(' \x1b[33mNo matches\x1b[0m');
90
141
 
91
- function render() {
92
- // Move cursor up to clear previous render
93
- if (cursor !== -1) {
94
- process.stdout.write(`\x1b[${options.length + 1}A\x1b[J`);
142
+ process.stdout.write(`${lines.join('\n')}\n`);
143
+ lastRenderedLines = lines.length;
144
+ }
145
+
146
+ draw();
147
+
148
+ process.stdin.setRawMode(true);
149
+ process.stdin.resume();
150
+
151
+ const onKeypress = (key) => {
152
+ if (key[0] === 27 && key[1] === 91 && key[2] === 65) {
153
+ if (filtered.length > 0) cursor = (cursor - 1 + filtered.length) % filtered.length;
154
+ draw();
155
+ } else if (key[0] === 27 && key[1] === 91 && key[2] === 66) {
156
+ if (filtered.length > 0) cursor = (cursor + 1) % filtered.length;
157
+ draw();
158
+ } else if (key[0] === 13 || key[0] === 10) {
159
+ process.stdin.setRawMode(false);
160
+ process.stdin.removeListener('data', onKeypress);
161
+ if (filtered.length > 0) resolve(filtered[cursor].value);
162
+ else process.exit(0);
163
+ } else if (key[0] === 3 || (!searchable && key[0] === 113)) {
164
+ process.stdin.setRawMode(false);
165
+ process.stdin.removeListener('data', onKeypress);
166
+ process.exit(0);
167
+ } else if (searchable && (key[0] === 127 || key[0] === 8)) {
168
+ filter = filter.slice(0, -1);
169
+ filtered = allOptions.filter((o) => o.label.toLowerCase().includes(filter.toLowerCase()));
170
+ cursor = 0;
171
+ draw();
172
+ } else if (searchable && key[0] >= 32 && key[0] < 127 && key.length === 1) {
173
+ filter += String.fromCharCode(key[0]);
174
+ filtered = allOptions.filter((o) => o.label.toLowerCase().includes(filter.toLowerCase()));
175
+ cursor = 0;
176
+ draw();
95
177
  }
178
+ };
96
179
 
97
- console.log(question);
180
+ process.stdin.on('data', onKeypress);
181
+ });
182
+ }
183
+
184
+ async function fetchTablesFromDatabase() {
185
+ try {
186
+ // Resolve modules from the project's node_modules, not langaro-api's
187
+ const projectRoot = process.cwd();
188
+ const resolveFrom = (mod) => require(require.resolve(mod, { paths: [projectRoot] }));
189
+
190
+ resolveFrom('dotenv').config({ path: path.join(projectRoot, '.env') });
191
+
192
+ const {
193
+ DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT,
194
+ } = process.env;
195
+
196
+ if (!DB_NAME) {
197
+ console.log('\x1b[33m Could not connect to database. DB_NAME not found in .env\x1b[0m');
198
+ return null;
199
+ }
200
+
201
+ const knex = resolveFrom('knex')({
202
+ client: 'mysql2',
203
+ connection: {
204
+ host: DB_HOST, user: DB_USER, password: DB_PASSWORD, database: DB_NAME, port: DB_PORT,
205
+ },
206
+ });
207
+
208
+ const results = await knex.raw(
209
+ 'SELECT table_name FROM information_schema.tables WHERE table_schema = ?',
210
+ [DB_NAME],
211
+ );
212
+
213
+ const tables = results[0].map((row) => row.TABLE_NAME).sort();
214
+ await knex.destroy();
215
+ return tables;
216
+ } catch (err) {
217
+ console.log(`\x1b[33m Could not connect to database: ${err.message}\x1b[0m`);
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function multiSelect(rl, question, options) {
223
+ return new Promise((resolve) => {
224
+ const selected = new Set(options.map((_, i) => i));
225
+ let cursor = 0;
226
+ let lastRenderedLines = 0;
227
+
228
+ function draw() {
229
+ if (lastRenderedLines > 0) {
230
+ const moveUp = Math.min(lastRenderedLines, (process.stdout.rows || 24) - 1);
231
+ process.stdout.write(`\x1b[${moveUp}A\x1b[J`);
232
+ }
233
+ const lines = [];
234
+ lines.push(question);
98
235
  options.forEach((opt, i) => {
99
236
  const check = selected.has(i) ? '\x1b[32m[x]\x1b[0m' : '[ ]';
100
237
  const pointer = i === cursor ? '\x1b[36m> \x1b[0m' : ' ';
101
- console.log(`${pointer}${check} ${opt}`);
238
+ lines.push(`${pointer}${check} ${opt}`);
102
239
  });
240
+ process.stdout.write(`${lines.join('\n')}\n`);
241
+ lastRenderedLines = lines.length;
103
242
  }
104
243
 
105
- // Initial render
106
- console.log(question);
107
- options.forEach((opt, i) => {
108
- const check = selected.has(i) ? '\x1b[32m[x]\x1b[0m' : '[ ]';
109
- const pointer = i === cursor ? '\x1b[36m> \x1b[0m' : ' ';
110
- console.log(`${pointer}${check} ${opt}`);
111
- });
244
+ draw();
112
245
 
113
246
  process.stdin.setRawMode(true);
114
247
  process.stdin.resume();
115
248
 
116
249
  const onKeypress = (key) => {
117
- // Up arrow
118
250
  if (key[0] === 27 && key[1] === 91 && key[2] === 65) {
119
251
  cursor = (cursor - 1 + options.length) % options.length;
120
- render();
121
- // Down arrow
252
+ draw();
122
253
  } else if (key[0] === 27 && key[1] === 91 && key[2] === 66) {
123
254
  cursor = (cursor + 1) % options.length;
124
- render();
125
- // Space — toggle selection
255
+ draw();
126
256
  } else if (key[0] === 32) {
127
- if (selected.has(cursor)) {
128
- selected.delete(cursor);
129
- } else {
130
- selected.add(cursor);
131
- }
132
- render();
133
- // Enter — confirm
257
+ if (selected.has(cursor)) selected.delete(cursor);
258
+ else selected.add(cursor);
259
+ draw();
134
260
  } else if (key[0] === 13 || key[0] === 10) {
135
261
  process.stdin.setRawMode(false);
136
262
  process.stdin.removeListener('data', onKeypress);
137
263
  resolve(options.filter((_, i) => selected.has(i)));
138
- // q or Ctrl+C — abort
139
264
  } else if (key[0] === 113 || key[0] === 3) {
140
265
  process.stdin.setRawMode(false);
141
266
  process.stdin.removeListener('data', onKeypress);
@@ -152,24 +277,46 @@ async function run(config = {}) {
152
277
  const root = cfg.root || process.cwd();
153
278
  const resolve = (dir) => path.resolve(root, dir);
154
279
 
155
- const rl = createInterface();
156
-
157
- const name = await ask(rl, '\n\x1b[36mResource name\x1b[0m (snake_case, e.g. products, user_invoices): ');
280
+ // Step 1: Choose how to pick the resource name
281
+ console.log('');
282
+ const source = await singleSelect('\x1b[36mHow to pick the resource name?\x1b[0m', [
283
+ { label: 'Enter manually', value: 'manual', desc: 'Type a snake_case name' },
284
+ { label: 'Fetch from database', value: 'db', desc: 'Select a table from your database' },
285
+ ]);
286
+
287
+ let name;
288
+
289
+ if (source === 'db') {
290
+ console.log('\n Connecting to database...');
291
+ const tables = await fetchTablesFromDatabase();
292
+
293
+ if (!tables || tables.length === 0) {
294
+ console.log('\x1b[31m No tables found.\x1b[0m Falling back to manual input.\n');
295
+ const rl = createInterface();
296
+ name = await ask(rl, '\x1b[36mResource name\x1b[0m (snake_case, e.g. products, user_invoices): ');
297
+ rl.close();
298
+ } else {
299
+ console.log('');
300
+ const tableOptions = tables.map((t) => ({ label: t, value: t }));
301
+ name = await singleSelect(` Found ${tables.length} tables. Select one:`, tableOptions);
302
+ }
303
+ } else {
304
+ const rl = createInterface();
305
+ name = await ask(rl, '\n\x1b[36mResource name\x1b[0m (snake_case, e.g. products, user_invoices): ');
306
+ rl.close();
307
+ }
158
308
 
159
309
  if (!name) {
160
310
  console.log('No name provided. Aborting.');
161
- rl.close();
162
311
  process.exit(0);
163
312
  }
164
313
 
165
314
  // Validate name
166
315
  if (!/^[a-z][a-z0-9_]*$/.test(name)) {
167
316
  console.log('\x1b[31mInvalid name.\x1b[0m Use snake_case (e.g. products, user_invoices)');
168
- rl.close();
169
317
  process.exit(1);
170
318
  }
171
319
 
172
- rl.close();
173
320
  console.log('');
174
321
 
175
322
  const choices = await multiSelect(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "langaro-api",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Auto-generate TypeScript types, JSDoc annotations, and boilerplate loaders for knex-extended-crud projects",
5
5
  "main": "lib/index.js",
6
6
  "bin": {