rad-coder 1.0.1 → 1.0.3

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/bin/cli.js CHANGED
@@ -13,66 +13,249 @@ function extractCreativeId(input) {
13
13
  return urlMatch ? urlMatch[1] : input;
14
14
  }
15
15
 
16
- // Get creative ID from command line argument
17
- const input = process.argv[2];
16
+ /**
17
+ * Prompt user with a question and arrow-key selectable choices
18
+ * @param {string} question - The question to ask
19
+ * @param {string[]} choices - Array of choices
20
+ * @returns {Promise<number>} - The index of the selected choice (0-based)
21
+ */
22
+ function promptUser(question, choices) {
23
+ return new Promise((resolve) => {
24
+ let selected = 0;
25
+
26
+ // Print question
27
+ console.log(`\n${question}\n`);
28
+
29
+ function draw() {
30
+ // Move cursor up to overwrite previous menu lines
31
+ if (selected !== -1) {
32
+ process.stdout.write(`\x1B[${choices.length}A`);
33
+ }
34
+ for (let i = 0; i < choices.length; i++) {
35
+ process.stdout.write('\x1B[2K'); // clear line
36
+ if (i === selected) {
37
+ process.stdout.write(` \x1B[36m\x1B[1m❯ ${choices[i]}\x1B[0m\n`);
38
+ } else {
39
+ process.stdout.write(` ${choices[i]}\n`);
40
+ }
41
+ }
42
+ }
43
+
44
+ // Initial draw (set selected to -1 so it doesn't move cursor up first time)
45
+ const initial = selected;
46
+ selected = -1;
47
+ // Print placeholder lines first
48
+ for (let i = 0; i < choices.length; i++) {
49
+ process.stdout.write('\n');
50
+ }
51
+ // Move back up and draw properly
52
+ process.stdout.write(`\x1B[${choices.length}A`);
53
+ selected = initial;
54
+ draw();
55
+
56
+ // Enable raw mode for keypress detection
57
+ if (process.stdin.isTTY) {
58
+ process.stdin.setRawMode(true);
59
+ }
60
+ process.stdin.resume();
61
+
62
+ function onData(data) {
63
+ const key = data.toString();
64
+
65
+ // Ctrl+C
66
+ if (key === '\x03') {
67
+ cleanup();
68
+ process.exit(0);
69
+ return;
70
+ }
71
+
72
+ // Up arrow
73
+ if (key === '\x1B[A') {
74
+ selected = Math.max(0, selected - 1);
75
+ draw();
76
+ return;
77
+ }
78
+
79
+ // Down arrow
80
+ if (key === '\x1B[B') {
81
+ selected = Math.min(choices.length - 1, selected + 1);
82
+ draw();
83
+ return;
84
+ }
85
+
86
+ // Enter
87
+ if (key === '\r' || key === '\n') {
88
+ cleanup();
89
+ resolve(selected);
90
+ return;
91
+ }
92
+
93
+ // Number keys for quick select
94
+ const num = parseInt(key, 10);
95
+ if (num >= 1 && num <= choices.length) {
96
+ selected = num - 1;
97
+ draw();
98
+ cleanup();
99
+ resolve(selected);
100
+ return;
101
+ }
102
+ }
103
+
104
+ function cleanup() {
105
+ process.stdin.removeListener('data', onData);
106
+ if (process.stdin.isTTY) {
107
+ process.stdin.setRawMode(false);
108
+ }
109
+ process.stdin.pause();
110
+ }
111
+
112
+ process.stdin.on('data', onData);
113
+ });
114
+ }
115
+
116
+ // Get creative ID from command line argument (skip flags)
117
+ const args = process.argv.slice(2);
118
+ let input = null;
119
+ let editorFlag = null;
120
+ let noEditor = false;
121
+
122
+ for (const arg of args) {
123
+ if (arg.startsWith('--editor=')) {
124
+ editorFlag = arg.split('=')[1];
125
+ } else if (arg === '--no-editor') {
126
+ noEditor = true;
127
+ } else if (!arg.startsWith('--')) {
128
+ input = arg;
129
+ }
130
+ }
131
+
132
+ // Pass editor preferences via environment variables
133
+ if (editorFlag) {
134
+ process.env.RAD_CODER_EDITOR = editorFlag;
135
+ }
136
+ if (noEditor) {
137
+ process.env.RAD_CODER_NO_EDITOR = '1';
138
+ }
139
+
18
140
  const creativeId = extractCreativeId(input);
19
141
 
20
142
  if (!creativeId) {
21
- console.log('Usage: npx rad-coder <creativeId or previewUrl>');
143
+ console.log('Usage: npx rad-coder <creativeId or previewUrl> [options]');
144
+ console.log('');
145
+ console.log('Options:');
146
+ console.log(' --editor=<cmd> Set code editor command (default: code)');
147
+ console.log(' --no-editor Don\'t auto-open code editor');
22
148
  console.log('');
23
149
  console.log('Examples:');
24
150
  console.log(' npx rad-coder 697b80fcc6e904025f5147a0');
25
151
  console.log(' npx rad-coder https://studio.responsiveads.com/creatives/697b80fcc6e904025f5147a0/preview');
152
+ console.log(' npx rad-coder 697b80fcc6e904025f5147a0 --editor=cursor');
26
153
  process.exit(1);
27
154
  }
28
155
 
29
- // Determine the target directory
30
- const cwd = process.cwd();
31
- const currentDirName = path.basename(cwd);
32
- let userDir;
33
-
34
- if (currentDirName === creativeId) {
35
- // Already in the correct folder
36
- userDir = cwd;
37
- console.log(`Using existing folder: ./${creativeId}`);
38
- } else {
39
- // Create or use a folder with the creative ID
40
- userDir = path.join(cwd, creativeId);
41
- if (!fs.existsSync(userDir)) {
42
- fs.mkdirSync(userDir);
43
- console.log(`Created folder: ./${creativeId}`);
44
- } else {
156
+ async function main() {
157
+ // Determine the target directory
158
+ const cwd = process.cwd();
159
+ const currentDirName = path.basename(cwd);
160
+ let userDir;
161
+
162
+ if (currentDirName === creativeId) {
163
+ // Already in the correct folder
164
+ userDir = cwd;
45
165
  console.log(`Using existing folder: ./${creativeId}`);
166
+ } else {
167
+ // Create or use a folder with the creative ID
168
+ userDir = path.join(cwd, creativeId);
169
+ if (!fs.existsSync(userDir)) {
170
+ fs.mkdirSync(userDir);
171
+ console.log(`Created folder: ./${creativeId}`);
172
+ } else {
173
+ console.log(`Using existing folder: ./${creativeId}`);
174
+ }
46
175
  }
47
- }
48
176
 
49
- // Files to copy to user's directory on first run
50
- const filesToCopy = [
51
- { template: 'custom.js', target: 'custom.js' },
52
- { template: 'AGENTS.md', target: 'AGENTS.md' }
53
- ];
54
-
55
- // Copy template files if they don't exist
56
- let filesCreated = false;
57
- filesToCopy.forEach(({ template, target }) => {
58
- const targetPath = path.join(userDir, target);
59
- if (!fs.existsSync(targetPath)) {
60
- const templatePath = path.join(packageRoot, 'templates', template);
177
+ // Set environment variables for the server
178
+ process.env.RAD_CODER_USER_DIR = userDir;
179
+ process.env.RAD_CODER_PACKAGE_DIR = packageRoot;
180
+
181
+ // Import server module to fetch creative config
182
+ const { fetchCreativeConfig, startServer } = require('../server/index.js');
183
+
184
+ // Fetch creative config first to check for customjs
185
+ const config = await fetchCreativeConfig(creativeId);
186
+
187
+ const customJsPath = path.join(userDir, 'custom.js');
188
+ const customJsExists = fs.existsSync(customJsPath);
189
+ const hasCreativeCustomJs = config.customjs && config.customjs.trim().length > 0;
190
+
191
+ // Handle custom.js file creation/update
192
+ if (hasCreativeCustomJs) {
193
+ if (!customJsExists) {
194
+ // custom.js doesn't exist - ask user what to use
195
+ const choice = await promptUser(
196
+ 'Found customJS in this creative. What would you like to use?',
197
+ [
198
+ 'Use customJS from the creative (recommended)',
199
+ 'Start with blank template'
200
+ ]
201
+ );
202
+
203
+ if (choice === 0) {
204
+ // Use customjs from creative
205
+ fs.writeFileSync(customJsPath, config.customjs, 'utf-8');
206
+ console.log(' Created custom.js (from creative)');
207
+ } else {
208
+ // Use template
209
+ const templatePath = path.join(packageRoot, 'templates', 'custom.js');
210
+ if (fs.existsSync(templatePath)) {
211
+ fs.copyFileSync(templatePath, customJsPath);
212
+ console.log(' Created custom.js (from template)');
213
+ }
214
+ }
215
+ } else {
216
+ // custom.js exists - ask user if they want to overwrite
217
+ const choice = await promptUser(
218
+ 'Found customJS in this creative. Your custom.js already exists.',
219
+ [
220
+ 'Keep existing custom.js',
221
+ 'Overwrite with customJS from creative'
222
+ ]
223
+ );
224
+
225
+ if (choice === 1) {
226
+ // Overwrite with creative's customjs
227
+ fs.writeFileSync(customJsPath, config.customjs, 'utf-8');
228
+ console.log(' Overwrote custom.js with creative\'s customJS');
229
+ } else {
230
+ console.log(' Keeping existing custom.js');
231
+ }
232
+ }
233
+ } else {
234
+ // No customjs in creative - use template if custom.js doesn't exist
235
+ if (!customJsExists) {
236
+ const templatePath = path.join(packageRoot, 'templates', 'custom.js');
237
+ if (fs.existsSync(templatePath)) {
238
+ fs.copyFileSync(templatePath, customJsPath);
239
+ console.log(' Created custom.js (from template)');
240
+ }
241
+ }
242
+ }
243
+
244
+ // Copy AGENTS.md if it doesn't exist
245
+ const agentsMdPath = path.join(userDir, 'AGENTS.md');
246
+ if (!fs.existsSync(agentsMdPath)) {
247
+ const templatePath = path.join(packageRoot, 'templates', 'AGENTS.md');
61
248
  if (fs.existsSync(templatePath)) {
62
- fs.copyFileSync(templatePath, targetPath);
63
- console.log(` Created ${target}`);
64
- filesCreated = true;
249
+ fs.copyFileSync(templatePath, agentsMdPath);
250
+ console.log(' Created AGENTS.md');
65
251
  }
66
252
  }
67
- });
68
253
 
69
- if (filesCreated) {
70
- console.log('');
254
+ // Start the server with pre-fetched config
255
+ await startServer(config);
71
256
  }
72
257
 
73
- // Set environment variables for the server
74
- process.env.RAD_CODER_USER_DIR = userDir;
75
- process.env.RAD_CODER_PACKAGE_DIR = packageRoot;
76
-
77
- // Run the server
78
- require('../server/index.js');
258
+ main().catch((err) => {
259
+ console.error('Error:', err.message);
260
+ process.exit(1);
261
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rad-coder",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Development environment for testing ResponsiveAds creative custom JS with hot-reload",
5
5
  "bin": {
6
6
  "rad-coder": "./bin/cli.js"
package/server/index.js CHANGED
@@ -5,6 +5,50 @@ const chokidar = require('chokidar');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
7
  const http = require('http');
8
+ const { spawn } = require('child_process');
9
+ const TUI = require('./tui');
10
+
11
+ // ============================================================
12
+ // TUI Instance & Logging
13
+ // ============================================================
14
+
15
+ let tui = null;
16
+
17
+ /**
18
+ * Log a message — routes to TUI scroll area when active, otherwise console.log
19
+ */
20
+ function log(message) {
21
+ if (tui && !tui.destroyed) {
22
+ tui.log(message);
23
+ } else {
24
+ console.log(message);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Open the user's code editor
30
+ */
31
+ function openEditor() {
32
+ const editorCmd = process.env.RAD_CODER_EDITOR
33
+ || process.env.VISUAL
34
+ || process.env.EDITOR
35
+ || 'code';
36
+
37
+ try {
38
+ const child = spawn(editorCmd, [userDir], {
39
+ detached: true,
40
+ stdio: 'ignore',
41
+ shell: process.platform === 'win32'
42
+ });
43
+ child.unref();
44
+ child.on('error', (err) => {
45
+ log(` ✗ Could not open editor "${editorCmd}": ${err.message}`);
46
+ });
47
+ log(` Editor (${editorCmd}) opened`);
48
+ } catch (err) {
49
+ log(` ✗ Could not open editor "${editorCmd}": ${err.message}`);
50
+ }
51
+ }
8
52
 
9
53
  // ============================================================
10
54
  // Directory Configuration
@@ -19,26 +63,33 @@ const packageDir = process.env.RAD_CODER_PACKAGE_DIR || path.join(__dirname, '..
19
63
  // CLI Argument Parsing
20
64
  // ============================================================
21
65
 
22
- const input = process.argv[2];
66
+ // Check if we're being required as a module (from cli.js) or run directly
67
+ const isModule = require.main !== module;
23
68
 
24
- if (!input) {
25
- console.error('\n Usage: npx rad-coder <creativeId or previewUrl>\n');
26
- console.error(' Examples:');
27
- console.error(' npx rad-coder 697b80fcc6e904025f5147a0');
28
- console.error(' npx rad-coder https://studio.responsiveads.com/creatives/697b80fcc6e904025f5147a0/preview\n');
29
- process.exit(1);
30
- }
69
+ let creativeId = null;
31
70
 
32
- /**
33
- * Extract creative ID from URL or use directly
34
- */
35
- function extractCreativeId(input) {
36
- // If it's a URL, extract the ID
37
- const urlMatch = input.match(/creatives\/([a-f0-9]+)/i);
38
- return urlMatch ? urlMatch[1] : input;
39
- }
71
+ if (!isModule) {
72
+ const input = process.argv[2];
40
73
 
41
- const creativeId = extractCreativeId(input);
74
+ if (!input) {
75
+ console.error('\n Usage: npx rad-coder <creativeId or previewUrl>\n');
76
+ console.error(' Examples:');
77
+ console.error(' npx rad-coder 697b80fcc6e904025f5147a0');
78
+ console.error(' npx rad-coder https://studio.responsiveads.com/creatives/697b80fcc6e904025f5147a0/preview\n');
79
+ process.exit(1);
80
+ }
81
+
82
+ /**
83
+ * Extract creative ID from URL or use directly
84
+ */
85
+ function extractCreativeIdLocal(input) {
86
+ // If it's a URL, extract the ID
87
+ const urlMatch = input.match(/creatives\/([a-f0-9]+)/i);
88
+ return urlMatch ? urlMatch[1] : input;
89
+ }
90
+
91
+ creativeId = extractCreativeIdLocal(input);
92
+ }
42
93
 
43
94
  // ============================================================
44
95
  // Fetch Creative Config from Studio Preview Page
@@ -46,14 +97,68 @@ const creativeId = extractCreativeId(input);
46
97
 
47
98
  let creativeConfig = null;
48
99
 
100
+ /**
101
+ * Extract a JSON object from HTML using balanced bracket parsing
102
+ * @param {string} html - The HTML content
103
+ * @param {string} startMarker - The marker to find (e.g., 'window.creative = ')
104
+ * @returns {object|null} - Parsed JSON object or null
105
+ */
106
+ function extractJsonObject(html, startMarker) {
107
+ const startIdx = html.indexOf(startMarker);
108
+ if (startIdx === -1) return null;
109
+
110
+ const jsonStart = startIdx + startMarker.length;
111
+ let braceCount = 0;
112
+ let inString = false;
113
+ let escapeNext = false;
114
+ let endIdx = jsonStart;
115
+
116
+ for (let i = jsonStart; i < html.length; i++) {
117
+ const char = html[i];
118
+
119
+ if (escapeNext) {
120
+ escapeNext = false;
121
+ continue;
122
+ }
123
+
124
+ if (char === '\\') {
125
+ escapeNext = true;
126
+ continue;
127
+ }
128
+
129
+ if (char === '"' && !inString) {
130
+ inString = true;
131
+ } else if (char === '"' && inString) {
132
+ inString = false;
133
+ }
134
+
135
+ if (!inString) {
136
+ if (char === '{') braceCount++;
137
+ if (char === '}') braceCount--;
138
+
139
+ if (braceCount === 0 && char === '}') {
140
+ endIdx = i + 1;
141
+ break;
142
+ }
143
+ }
144
+ }
145
+
146
+ try {
147
+ const jsonStr = html.substring(jsonStart, endIdx);
148
+ return JSON.parse(jsonStr);
149
+ } catch (err) {
150
+ return null;
151
+ }
152
+ }
153
+
49
154
  /**
50
155
  * Fetch and parse creative configuration from studio preview page
51
156
  */
52
157
  async function fetchCreativeConfig(creativeId) {
53
158
  const previewUrl = `https://studio.responsiveads.com/creatives/${creativeId}/preview`;
54
159
 
55
- console.log(` Fetching creative config from studio...`);
56
- console.log(` URL: ${previewUrl}\n`);
160
+ log(` Fetching creative config from studio...`);
161
+ log(` URL: ${previewUrl}\n`);
57
162
 
58
163
  try {
59
164
  const response = await fetch(previewUrl);
@@ -67,6 +172,13 @@ async function fetchCreativeConfig(creativeId) {
67
172
  const creativeIdMatch = html.match(/window\.creativeId\s*=\s*['"]([^'"]+)['"]/);
68
173
  const extractedCreativeId = creativeIdMatch ? creativeIdMatch[1] : creativeId;
69
174
 
175
+ // Extract window.creative object to get customjs
176
+ let customjs = null;
177
+ const creativeObj = extractJsonObject(html, 'window.creative = ');
178
+ if (creativeObj && creativeObj.config && creativeObj.config.customjs) {
179
+ customjs = creativeObj.config.customjs;
180
+ }
181
+
70
182
  // Extract flowlines - try multiple patterns
71
183
  let flowlines;
72
184
 
@@ -187,11 +299,13 @@ async function fetchCreativeConfig(creativeId) {
187
299
  name: f.name,
188
300
  sizes: f.flowline?.sizes || [],
189
301
  isFluid: f.fullyFluid
190
- }))
302
+ })),
303
+ // Custom JS from the creative (if available)
304
+ customjs: customjs
191
305
  };
192
306
 
193
307
  } catch (error) {
194
- console.error(`\n Failed to fetch creative config: ${error.message}\n`);
308
+ log(`\n Failed to fetch creative config: ${error.message}\n`);
195
309
  process.exit(1);
196
310
  }
197
311
  }
@@ -211,11 +325,11 @@ const clients = new Set();
211
325
 
212
326
  wss.on('connection', (ws) => {
213
327
  clients.add(ws);
214
- console.log('Browser connected for hot-reload');
328
+ log(' Browser connected for hot-reload');
215
329
 
216
330
  ws.on('close', () => {
217
331
  clients.delete(ws);
218
- console.log('Browser disconnected');
332
+ log(' Browser disconnected');
219
333
  });
220
334
  });
221
335
 
@@ -271,27 +385,31 @@ const watcher = chokidar.watch(customJsWatchPath, {
271
385
  ignoreInitial: true
272
386
  });
273
387
 
274
- watcher.on('change', (filePath) => {
275
- console.log(`\n File changed: ${path.basename(filePath)}`);
276
- console.log(' Reloading browsers...\n');
388
+ watcher.on('change', (changedPath) => {
389
+ log(` File changed: ${path.basename(changedPath)}`);
390
+ log(' Reloading browsers...');
277
391
  broadcastReload();
278
392
  });
279
393
 
280
394
  watcher.on('error', (error) => {
281
- console.error('Watcher error:', error);
395
+ log(` Watcher error: ${error.message}`);
282
396
  });
283
397
 
284
398
  // ============================================================
285
399
  // Start Server
286
400
  // ============================================================
287
401
 
288
- async function start() {
402
+ async function start(prefetchedConfig = null) {
289
403
  console.log('\n========================================');
290
404
  console.log(' RAD Coder - ResponsiveAds Creative Tester');
291
405
  console.log('========================================\n');
292
406
 
293
- // Fetch creative config from studio
294
- creativeConfig = await fetchCreativeConfig(creativeId);
407
+ // Use pre-fetched config if provided, otherwise fetch it
408
+ if (prefetchedConfig) {
409
+ creativeConfig = prefetchedConfig;
410
+ } else {
411
+ creativeConfig = await fetchCreativeConfig(creativeId);
412
+ }
295
413
 
296
414
  console.log(' Creative Config:');
297
415
  console.log(` - Creative ID: ${creativeConfig.creativeId}`);
@@ -299,6 +417,7 @@ async function start() {
299
417
  console.log(` - Flowline ID: ${creativeConfig.flowlineId}`);
300
418
  console.log(` - Sizes: ${creativeConfig.sizes.join(', ')}`);
301
419
  console.log(` - Is Fluid: ${creativeConfig.isFluid}`);
420
+ console.log(` - Has CustomJS: ${creativeConfig.customjs ? 'Yes' : 'No'}`);
302
421
 
303
422
  if (creativeConfig.allFlowlines.length > 1) {
304
423
  console.log(`\n Available Flowlines (${creativeConfig.allFlowlines.length}):`);
@@ -315,7 +434,6 @@ async function start() {
315
434
  console.log(` Test page: http://${host}:${port}/test.html`);
316
435
  console.log(`\n Working directory: ${userDir}`);
317
436
  console.log(' Edit custom.js and save to hot-reload\n');
318
- console.log(' Press Ctrl+C to stop\n');
319
437
 
320
438
  // Small delay to ensure server is fully ready before opening browser
321
439
  await new Promise(resolve => setTimeout(resolve, 500));
@@ -324,18 +442,175 @@ async function start() {
324
442
  try {
325
443
  const open = (await import('open')).default;
326
444
  await open(`http://${host}:${port}/test.html`);
327
- console.log(' Browser opened automatically\n');
445
+ console.log(' Browser opened automatically');
328
446
  } catch (err) {
329
- console.log(' Could not auto-open browser:', err.message);
330
- console.log(` Please open http://${host}:${port}/test.html manually\n`);
447
+ console.log(` Could not auto-open browser: ${err.message}`);
448
+ console.log(` Please open http://${host}:${port}/test.html manually`);
449
+ }
450
+
451
+ // Auto-open editor (unless --no-editor)
452
+ if (!process.env.RAD_CODER_NO_EDITOR) {
453
+ openEditor();
454
+ }
455
+
456
+ // Start interactive TUI (only if stdin is a TTY)
457
+ if (process.stdin.isTTY) {
458
+ await new Promise(resolve => setTimeout(resolve, 300));
459
+ startInteractiveMenu();
460
+ } else {
461
+ console.log(' Press Ctrl+C to stop\n');
331
462
  }
332
463
  });
333
464
  }
334
465
 
335
- start();
466
+ // ============================================================
467
+ // Interactive Menu
468
+ // ============================================================
469
+
470
+ function getMainMenuItems() {
471
+ const items = [
472
+ { label: 'Open Browser', id: 'open-browser' },
473
+ { label: 'Open Editor', id: 'open-editor' },
474
+ ];
475
+
476
+ if (creativeConfig && creativeConfig.allFlowlines.length > 1) {
477
+ items.push({ label: 'Switch Flowline', id: 'switch-flowline', description: `(${creativeConfig.flowlineName})` });
478
+ }
479
+
480
+ items.push(
481
+ { label: 'Server Status', id: 'status' },
482
+ { label: 'Clear Logs', id: 'clear' },
483
+ { label: 'Restart Server', id: 'restart' },
484
+ { label: 'Stop Server', id: 'stop' },
485
+ );
486
+
487
+ return items;
488
+ }
489
+
490
+ function getFlowlineMenuItems() {
491
+ const items = [{ label: '← Back', id: 'back' }];
492
+ creativeConfig.allFlowlines.forEach((fl, i) => {
493
+ const marker = fl.id === creativeConfig.flowlineId ? ' ✓' : '';
494
+ items.push({ label: `${fl.name}${marker}`, id: `flowline-${i}`, flowlineIndex: i });
495
+ });
496
+ return items;
497
+ }
498
+
499
+ function startInteractiveMenu() {
500
+ tui = new TUI();
501
+
502
+ let inSubMenu = false;
503
+
504
+ function handleSelect(item) {
505
+ // Sub-menu: flowline selection
506
+ if (inSubMenu) {
507
+ if (item.id === 'back') {
508
+ inSubMenu = false;
509
+ tui.updateMenu(getMainMenuItems());
510
+ return;
511
+ }
512
+ // Switch flowline
513
+ const fl = creativeConfig.allFlowlines[item.flowlineIndex];
514
+ if (fl) {
515
+ creativeConfig.flowlineId = fl.id;
516
+ creativeConfig.flowlineName = fl.name;
517
+ creativeConfig.sizes = fl.sizes || [];
518
+ creativeConfig.isFluid = fl.isFluid || false;
519
+ log(` Switched to flowline: ${fl.name}`);
520
+ broadcastReload();
521
+ }
522
+ inSubMenu = false;
523
+ tui.updateMenu(getMainMenuItems());
524
+ return;
525
+ }
526
+
527
+ // Main menu actions
528
+ switch (item.id) {
529
+ case 'open-browser': {
530
+ const { port, host } = creativeConfig.server;
531
+ import('open').then(mod => {
532
+ mod.default(`http://${host}:${port}/test.html`);
533
+ log(' Browser opened');
534
+ }).catch(err => {
535
+ log(` Could not open browser: ${err.message}`);
536
+ });
537
+ break;
538
+ }
539
+
540
+ case 'open-editor':
541
+ openEditor();
542
+ break;
543
+
544
+ case 'switch-flowline':
545
+ inSubMenu = true;
546
+ tui.updateMenu(getFlowlineMenuItems());
547
+ break;
548
+
549
+ case 'status': {
550
+ const { port, host } = creativeConfig.server;
551
+ log('');
552
+ log(' ── Server Status ──────────────────');
553
+ log(` Creative ID : ${creativeConfig.creativeId}`);
554
+ log(` Flowline : ${creativeConfig.flowlineName}`);
555
+ log(` Flowline ID : ${creativeConfig.flowlineId}`);
556
+ log(` Sizes : ${creativeConfig.sizes.join(', ') || 'N/A'}`);
557
+ log(` Is Fluid : ${creativeConfig.isFluid}`);
558
+ log(` Server : http://${host}:${port}`);
559
+ log(` Directory : ${userDir}`);
560
+ log(` Browsers : ${clients.size} connected`);
561
+ log(' ───────────────────────────────────');
562
+ log('');
563
+ break;
564
+ }
565
+
566
+ case 'clear':
567
+ tui.clearLogs();
568
+ break;
569
+
570
+ case 'restart': {
571
+ log(' Restarting server...');
572
+ const { port, host } = creativeConfig.server;
573
+ server.close(() => {
574
+ server.listen(port, host, () => {
575
+ log(` Server restarted on http://${host}:${port}`);
576
+ broadcastReload();
577
+ });
578
+ });
579
+ break;
580
+ }
581
+
582
+ case 'stop':
583
+ gracefulShutdown();
584
+ break;
585
+ }
586
+ }
587
+
588
+ tui.init({
589
+ menuItems: getMainMenuItems(),
590
+ onSelect: handleSelect,
591
+ });
592
+ }
593
+
594
+ // ============================================================
595
+ // Module Exports & Startup
596
+ // ============================================================
597
+
598
+ // Export functions for use by cli.js
599
+ module.exports = {
600
+ fetchCreativeConfig,
601
+ startServer: start
602
+ };
603
+
604
+ // Only auto-start if run directly (not required as module)
605
+ if (!isModule) {
606
+ start();
607
+ }
336
608
 
337
609
  // Graceful shutdown
338
- process.on('SIGINT', () => {
610
+ function gracefulShutdown() {
611
+ if (tui) {
612
+ tui.destroy();
613
+ }
339
614
  console.log('\n Shutting down...');
340
615
  watcher.close();
341
616
  wss.close();
@@ -343,4 +618,6 @@ process.on('SIGINT', () => {
343
618
  console.log(' Server stopped\n');
344
619
  process.exit(0);
345
620
  });
346
- });
621
+ }
622
+
623
+ process.on('SIGINT', gracefulShutdown);
package/server/tui.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Terminal UI Module
3
+ *
4
+ * Provides an interactive arrow-key menu pinned at the bottom of the terminal
5
+ * with a scrolling log area above it. Uses raw ANSI escape codes — zero dependencies.
6
+ */
7
+
8
+ const readline = require('readline');
9
+
10
+ // ANSI escape helpers
11
+ const ESC = '\x1B';
12
+ const CSI = `${ESC}[`;
13
+
14
+ const ansi = {
15
+ clearScreen: `${CSI}2J`,
16
+ clearLine: `${CSI}2K`,
17
+ cursorTo: (row, col) => `${CSI}${row};${col}H`,
18
+ cursorSave: `${ESC}7`,
19
+ cursorRestore: `${ESC}8`,
20
+ scrollRegion: (top, bottom) => `${CSI}${top};${bottom}r`,
21
+ resetScrollRegion: `${CSI}r`,
22
+ showCursor: `${CSI}?25h`,
23
+ hideCursor: `${CSI}?25l`,
24
+ bold: `${CSI}1m`,
25
+ dim: `${CSI}2m`,
26
+ cyan: `${CSI}36m`,
27
+ green: `${CSI}32m`,
28
+ yellow: `${CSI}33m`,
29
+ inverse: `${CSI}7m`,
30
+ reset: `${CSI}0m`,
31
+ };
32
+
33
+ class TUI {
34
+ constructor() {
35
+ this.menuItems = [];
36
+ this.selectedIndex = 0;
37
+ this.onSelect = null;
38
+ this.destroyed = false;
39
+ this.menuHeight = 0; // computed from items + header + border lines
40
+ this._boundKeyHandler = this._handleKeypress.bind(this);
41
+ this._boundResize = this._handleResize.bind(this);
42
+ }
43
+
44
+ /**
45
+ * Initialize the TUI
46
+ * @param {Object} options
47
+ * @param {Array<{label: string, description?: string}>} options.menuItems
48
+ * @param {Function} options.onSelect - callback(item, index)
49
+ */
50
+ init(options) {
51
+ this.menuItems = options.menuItems || [];
52
+ this.onSelect = options.onSelect || (() => {});
53
+ this.selectedIndex = 0;
54
+ this.menuHeight = this.menuItems.length + 3; // items + header + top/bottom borders
55
+
56
+ // Set raw mode for keypress detection
57
+ if (process.stdin.isTTY) {
58
+ process.stdin.setRawMode(true);
59
+ process.stdin.resume();
60
+ process.stdin.on('data', this._boundKeyHandler);
61
+ }
62
+
63
+ // Handle terminal resize
64
+ process.stdout.on('resize', this._boundResize);
65
+
66
+ // Initial draw
67
+ this._setupScreen();
68
+ this._drawMenu();
69
+ }
70
+
71
+ /**
72
+ * Set up ANSI scrolling region (log area = top, menu = bottom)
73
+ */
74
+ _setupScreen() {
75
+ const rows = process.stdout.rows || 24;
76
+ const logBottom = rows - this.menuHeight;
77
+
78
+ // Set scrolling region to the top portion only
79
+ process.stdout.write(ansi.scrollRegion(1, logBottom));
80
+
81
+ // Position cursor in the log area
82
+ process.stdout.write(ansi.cursorTo(logBottom, 1));
83
+ }
84
+
85
+ /**
86
+ * Handle terminal resize
87
+ */
88
+ _handleResize() {
89
+ if (this.destroyed) return;
90
+ this._setupScreen();
91
+ this._drawMenu();
92
+ }
93
+
94
+ /**
95
+ * Handle raw keypress data
96
+ */
97
+ _handleKeypress(data) {
98
+ if (this.destroyed) return;
99
+
100
+ const key = data.toString();
101
+
102
+ // Ctrl+C
103
+ if (key === '\x03') {
104
+ this.destroy();
105
+ process.emit('SIGINT');
106
+ return;
107
+ }
108
+
109
+ // Up arrow
110
+ if (key === `${CSI}A`) {
111
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
112
+ this._drawMenu();
113
+ return;
114
+ }
115
+
116
+ // Down arrow
117
+ if (key === `${CSI}B`) {
118
+ this.selectedIndex = Math.min(this.menuItems.length - 1, this.selectedIndex + 1);
119
+ this._drawMenu();
120
+ return;
121
+ }
122
+
123
+ // Enter
124
+ if (key === '\r' || key === '\n') {
125
+ const item = this.menuItems[this.selectedIndex];
126
+ if (item && this.onSelect) {
127
+ this.onSelect(item, this.selectedIndex);
128
+ }
129
+ return;
130
+ }
131
+
132
+ // Number keys 1-9 for quick select
133
+ const num = parseInt(key, 10);
134
+ if (num >= 1 && num <= this.menuItems.length) {
135
+ this.selectedIndex = num - 1;
136
+ this._drawMenu();
137
+ const item = this.menuItems[this.selectedIndex];
138
+ if (item && this.onSelect) {
139
+ this.onSelect(item, this.selectedIndex);
140
+ }
141
+ return;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Draw the menu at the bottom of the terminal
147
+ */
148
+ _drawMenu() {
149
+ if (this.destroyed) return;
150
+
151
+ const rows = process.stdout.rows || 24;
152
+ const cols = process.stdout.cols || 80;
153
+ const menuStartRow = rows - this.menuHeight + 1;
154
+
155
+ // Save cursor position (in log area)
156
+ let output = ansi.cursorSave;
157
+ output += ansi.hideCursor;
158
+
159
+ // Draw top border
160
+ output += ansi.cursorTo(menuStartRow, 1);
161
+ output += ansi.clearLine;
162
+ output += `${ansi.dim}${'─'.repeat(Math.min(cols, 60))}${ansi.reset}`;
163
+
164
+ // Draw header
165
+ output += ansi.cursorTo(menuStartRow + 1, 1);
166
+ output += ansi.clearLine;
167
+ output += `${ansi.bold} ↑↓ Navigate Enter Select 1-${this.menuItems.length} Quick Select${ansi.reset}`;
168
+
169
+ // Draw menu items
170
+ for (let i = 0; i < this.menuItems.length; i++) {
171
+ const row = menuStartRow + 2 + i;
172
+ const item = this.menuItems[i];
173
+ const isSelected = i === this.selectedIndex;
174
+
175
+ output += ansi.cursorTo(row, 1);
176
+ output += ansi.clearLine;
177
+
178
+ if (isSelected) {
179
+ output += `${ansi.cyan}${ansi.bold} ❯ ${item.label}${ansi.reset}`;
180
+ if (item.description) {
181
+ output += `${ansi.dim} ${item.description}${ansi.reset}`;
182
+ }
183
+ } else {
184
+ output += ` ${item.label}`;
185
+ if (item.description) {
186
+ output += `${ansi.dim} ${item.description}${ansi.reset}`;
187
+ }
188
+ }
189
+ }
190
+
191
+ // Restore cursor position (back to log area)
192
+ output += ansi.cursorRestore;
193
+ output += ansi.showCursor;
194
+
195
+ process.stdout.write(output);
196
+ }
197
+
198
+ /**
199
+ * Update menu items (e.g., for sub-menus)
200
+ */
201
+ updateMenu(items) {
202
+ const oldHeight = this.menuHeight;
203
+ this.menuItems = items;
204
+ this.selectedIndex = 0;
205
+ this.menuHeight = items.length + 3;
206
+
207
+ if (this.menuHeight !== oldHeight) {
208
+ // Clear old menu area
209
+ const rows = process.stdout.rows || 24;
210
+ const oldMenuStart = rows - oldHeight + 1;
211
+ let clear = '';
212
+ for (let i = oldMenuStart; i <= rows; i++) {
213
+ clear += ansi.cursorTo(i, 1) + ansi.clearLine;
214
+ }
215
+ process.stdout.write(clear);
216
+
217
+ // Reconfigure scroll region
218
+ this._setupScreen();
219
+ }
220
+
221
+ this._drawMenu();
222
+ }
223
+
224
+ /**
225
+ * Add a log message to the scrolling log area
226
+ */
227
+ log(message) {
228
+ if (this.destroyed) {
229
+ console.log(message);
230
+ return;
231
+ }
232
+
233
+ const rows = process.stdout.rows || 24;
234
+ const logBottom = rows - this.menuHeight;
235
+
236
+ // Save cursor, move to bottom of log area, print message (scrolls within region)
237
+ let output = ansi.cursorSave;
238
+ output += ansi.cursorTo(logBottom, 1);
239
+ output += '\n' + message;
240
+ output += ansi.cursorRestore;
241
+
242
+ process.stdout.write(output);
243
+ }
244
+
245
+ /**
246
+ * Clear the log area
247
+ */
248
+ clearLogs() {
249
+ const rows = process.stdout.rows || 24;
250
+ const logBottom = rows - this.menuHeight;
251
+
252
+ let output = '';
253
+ for (let i = 1; i <= logBottom; i++) {
254
+ output += ansi.cursorTo(i, 1) + ansi.clearLine;
255
+ }
256
+ output += ansi.cursorTo(1, 1);
257
+ process.stdout.write(output);
258
+ }
259
+
260
+ /**
261
+ * Destroy the TUI and restore terminal state
262
+ */
263
+ destroy() {
264
+ if (this.destroyed) return;
265
+ this.destroyed = true;
266
+
267
+ // Remove listeners
268
+ process.stdin.removeListener('data', this._boundKeyHandler);
269
+ process.stdout.removeListener('resize', this._boundResize);
270
+
271
+ // Restore terminal
272
+ process.stdout.write(ansi.resetScrollRegion);
273
+ process.stdout.write(ansi.showCursor);
274
+
275
+ if (process.stdin.isTTY) {
276
+ process.stdin.setRawMode(false);
277
+ }
278
+ process.stdin.pause();
279
+ }
280
+ }
281
+
282
+ module.exports = TUI;