bunosh 0.5.8 → 0.6.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/README.md CHANGED
@@ -448,6 +448,8 @@ export async function checkServices() {
448
448
  }
449
449
  ```
450
450
 
451
+ `task.try` fully isolates failures from the exit code. Any `shell`, `fetch`, `task`, or `assert` that fails inside the callback is recorded as a warning (yellow), never as a failed task — so the run still exits with code `0` if the rest succeeded. `task.stopOnFailures()` is also suppressed inside `task.try`: an inner failure will never call `process.exit(1)`. The return value (`true`/`false`) is the only signal you act on.
452
+
451
453
  ## Documentation
452
454
 
453
455
  - **[Examples](docs/examples.md)** — Real-world examples and workflows
package/bunosh.js CHANGED
@@ -106,6 +106,39 @@ async function loadBunoshfiles(tasksFile) {
106
106
 
107
107
  async function main() {
108
108
 
109
+ const serverIndex = process.argv.indexOf('-S');
110
+ if (serverIndex !== -1) {
111
+ let serverAddress = '127.0.0.1:7171';
112
+
113
+ if (serverIndex + 1 < process.argv.length && !process.argv[serverIndex + 1].startsWith('-')) {
114
+ serverAddress = process.argv[serverIndex + 1];
115
+ }
116
+
117
+ let host = '127.0.0.1';
118
+ let port = 7171;
119
+
120
+ if (serverAddress.startsWith('http://') || serverAddress.startsWith('https://')) {
121
+ try {
122
+ const url = new URL(serverAddress);
123
+ host = url.hostname;
124
+ port = parseInt(url.port) || 7171;
125
+ } catch (error) {
126
+ console.error('Invalid URL format:', serverAddress);
127
+ process.exit(1);
128
+ }
129
+ } else if (serverAddress.includes(':')) {
130
+ [host, port] = serverAddress.split(':');
131
+ port = parseInt(port);
132
+ } else if (!isNaN(parseInt(serverAddress))) {
133
+ port = parseInt(serverAddress);
134
+ }
135
+
136
+ const BunoshWebServer = (await import('./src/server.js')).default;
137
+ const server = new BunoshWebServer(port, host);
138
+ await server.start();
139
+ return;
140
+ }
141
+
109
142
  const bunoshfileIndex = process.argv.indexOf('--bunoshfile');
110
143
  let customBunoshfile = null;
111
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunosh",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "description": "Task runner that turns JavaScript functions into CLI commands. Runs on Bun and Node.js.",
5
5
  "type": "module",
6
6
  "module": "index.js",
@@ -37,6 +37,7 @@
37
37
  "@babel/parser": "^7.27.5",
38
38
  "@babel/traverse": "^7.27.4",
39
39
  "ai": "^5.0.29",
40
+ "ansi-to-html": "^0.7.2",
40
41
  "chalk": "^5.4.1",
41
42
  "commander": "^14.0.0",
42
43
  "debug": "^4.4.1",
@@ -10,13 +10,7 @@ export class GitHubActionsFormatter extends BaseFormatter {
10
10
  return `::group::${fullTaskName}`;
11
11
 
12
12
  case 'finish':
13
- const duration = extra.duration ? `${extra.duration}ms` : '';
14
- const extraInfo = Object.entries(extra)
15
- .filter(([k, v]) => v !== null && v !== undefined && k !== 'duration')
16
- .map(([k, v]) => `${k}: ${v}`)
17
- .join(', ');
18
- const details = [duration, extraInfo].filter(Boolean).join(', ');
19
- return `::endgroup::\n::notice::✅ ${fullTaskName}${details ? ` (${details})` : ''}`;
13
+ return `::endgroup::`;
20
14
 
21
15
  case 'error':
22
16
  const errorDetails = extra.error ? ` - ${extra.error}` : '';
package/src/io.js CHANGED
@@ -30,6 +30,14 @@ export async function ask(question, defaultValueOrOptions = {}, options = {}) {
30
30
  opts = { ...defaultValueOrOptions, ...options };
31
31
  }
32
32
 
33
+ if (globalThis._bunoshUIMode && globalThis._bunoshUIServer) {
34
+ try {
35
+ return await globalThis._bunoshUIServer.askInUI(question, opts);
36
+ } finally {
37
+ globalThis._bunoshInAskOperation = false;
38
+ }
39
+ }
40
+
33
41
  // Route to appropriate handler based on options
34
42
  if (opts.editor || opts.multiline) {
35
43
  return await askWithEditor(question, opts);
package/src/program.js CHANGED
@@ -418,6 +418,19 @@ export default async function bunosh(commands, sources) {
418
418
 
419
419
  internalCommands.push(upgradeCmd);
420
420
 
421
+ const uiCmd = program.command('ui')
422
+ .description('Launch the Bunosh web UI server')
423
+ .option('-p, --port <port>', 'Port to listen on', '7171')
424
+ .option('-H, --host <host>', 'Host to bind to', '127.0.0.1')
425
+ .action(async (options) => {
426
+ const { default: BunoshWebServer } = await import('./server.js');
427
+ const server = new BunoshWebServer(parseInt(options.port), options.host);
428
+ await server.start();
429
+ console.log(color.dim('Press Ctrl+C to stop.'));
430
+ });
431
+
432
+ internalCommands.push(uiCmd);
433
+
421
434
 
422
435
  let helpText = '';
423
436
 
@@ -512,6 +525,7 @@ ${namespaceCommands}
512
525
 
513
526
  if (helpFlagRequested) {
514
527
  helpText += color.dim(`Special Commands:
528
+ ${color.bold('bunosh ui')} 🌐 Launch web UI server (default :7171)
515
529
  ${color.bold('bunosh edit')} 📝 Edit bunosh file with $EDITOR
516
530
  ${color.bold('bunosh export:scripts')} 📥 Export commands to package.json
517
531
  ${color.bold('bunosh upgrade')} 🦾 Upgrade bunosh
package/src/server.js ADDED
@@ -0,0 +1,1025 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import path from 'path';
3
+ import babelParser from "@babel/parser";
4
+ import traverseDefault from "@babel/traverse";
5
+ import Convert from 'ansi-to-html';
6
+
7
+ const traverse = traverseDefault.default || traverseDefault;
8
+
9
+ function camelToDasherize(camelCaseString) {
10
+ return camelCaseString.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
11
+ }
12
+
13
+ class BunoshWebServer {
14
+ constructor(port = 7171, host = '127.0.0.1') {
15
+ this.port = port;
16
+ this.host = host;
17
+ this.server = null;
18
+ this.clients = new Set();
19
+ this.commands = {};
20
+ this.bunoshSource = '';
21
+ this.pendingAskPromises = new Map();
22
+ this.ansiConverter = new Convert({
23
+ fg: '#e5e5e5',
24
+ bg: '#1a1a1a',
25
+ newline: false,
26
+ escapeXML: true,
27
+ stream: false
28
+ });
29
+ }
30
+
31
+ async start() {
32
+ await this.loadCommands();
33
+
34
+ this.server = Bun.serve({
35
+ port: this.port,
36
+ hostname: this.host,
37
+ fetch: (req, server) => this.handleRequest(req, server),
38
+ websocket: {
39
+ message: (ws, data) => this.handleWebSocketMessage(ws, data),
40
+ open: (ws) => this.clients.add(ws),
41
+ close: (ws) => this.clients.delete(ws),
42
+ },
43
+ });
44
+
45
+ console.log(`Bunosh UI server running at http://${this.host}:${this.port}`);
46
+ }
47
+
48
+ async loadCommands() {
49
+ const BUNOSHFILE = 'Bunoshfile.js';
50
+ const bunoshfilePath = path.join(process.cwd(), BUNOSHFILE);
51
+
52
+ if (!existsSync(bunoshfilePath)) {
53
+ throw new Error(`Bunoshfile not found: ${bunoshfilePath}`);
54
+ }
55
+
56
+ this.bunoshfilePath = bunoshfilePath;
57
+ this.bunoshSource = readFileSync(bunoshfilePath, 'utf-8');
58
+ const tasks = await import(bunoshfilePath);
59
+
60
+ this.commands = this.parseCommands(tasks, this.bunoshSource);
61
+ }
62
+
63
+ parseCommands(tasks, source) {
64
+ const commands = {};
65
+ let completeAst;
66
+
67
+ try {
68
+ completeAst = babelParser.parse(source, {
69
+ sourceType: "module",
70
+ ranges: true,
71
+ tokens: true,
72
+ comments: true,
73
+ attachComment: true,
74
+ });
75
+ } catch (parseError) {
76
+ throw new Error(`Failed to parse Bunoshfile: ${parseError.message}`);
77
+ }
78
+
79
+ const comments = this.fetchComments(completeAst, source);
80
+
81
+ Object.keys(tasks).forEach((fnName) => {
82
+ if (typeof tasks[fnName] !== 'function') return;
83
+
84
+ const fnBody = tasks[fnName].toString();
85
+ const ast = this.fetchFnAst(fnName, completeAst, fnBody);
86
+ const args = this.parseArgs(fnName, ast);
87
+ const opts = this.parseOpts(fnName, ast);
88
+ const comment = comments[fnName];
89
+ const commandName = this.prepareCommandName(fnName);
90
+
91
+ commands[commandName] = {
92
+ name: commandName,
93
+ originalName: fnName,
94
+ description: comment?.split('\n')[0] || '',
95
+ args,
96
+ opts,
97
+ code: this.fetchFnSource(fnName, completeAst, source),
98
+ fn: tasks[fnName]
99
+ };
100
+ });
101
+
102
+ return commands;
103
+ }
104
+
105
+ fetchComments(completeAst, source) {
106
+ const comments = {};
107
+ let startFromLine = 0;
108
+
109
+ traverse(completeAst, {
110
+ FunctionDeclaration(path) {
111
+ const functionName = path.node.id && path.node.id.name;
112
+
113
+ const commentSource = source
114
+ .split("\n")
115
+ .slice(startFromLine, path.node?.loc?.start?.line)
116
+ .join("\n");
117
+ const matches = commentSource.match(
118
+ /\/\*\*\s([\s\S]*)\\*\/\s*export/,
119
+ );
120
+
121
+ if (matches && matches[1]) {
122
+ comments[functionName] = matches[1]
123
+ .replace(/^\s*\*\s*/gm, "")
124
+ .replace(/\s*\*\*\s*$/gm, "")
125
+ .trim()
126
+ .replace(/^@.*$/gm, "")
127
+ .trim();
128
+ } else {
129
+ const firstStatement = path.node?.body?.body?.[0];
130
+ const leadingComments = firstStatement?.leadingComments;
131
+
132
+ if (leadingComments && leadingComments.length > 0) {
133
+ comments[functionName] = leadingComments[0].value.trim();
134
+ }
135
+ }
136
+
137
+ startFromLine = path.node?.loc?.end?.line;
138
+ },
139
+ });
140
+
141
+ return comments;
142
+ }
143
+
144
+ fetchFnAst(fnName, completeAst, fnBody) {
145
+ let hasFnInSource = false;
146
+
147
+ traverse(completeAst, {
148
+ FunctionDeclaration(path) {
149
+ if (path.node.id.name == fnName) {
150
+ hasFnInSource = true;
151
+ return;
152
+ }
153
+ },
154
+ });
155
+
156
+ if (hasFnInSource) return completeAst;
157
+ return babelParser.parse(fnBody, { comment: true, tokens: true });
158
+ }
159
+
160
+ fetchFnSource(fnName, completeAst, source) {
161
+ let code = '';
162
+ traverse(completeAst, {
163
+ FunctionDeclaration(path) {
164
+ if (path.node.id?.name !== fnName) return;
165
+ const target = path.parentPath && path.parentPath.isExportNamedDeclaration()
166
+ ? path.parentPath.node
167
+ : path.node;
168
+ let startOffset = target.start;
169
+ const lead = target.leadingComments;
170
+ if (lead && lead.length) startOffset = lead[0].start;
171
+ code = source.slice(startOffset, target.end).trim();
172
+ path.stop();
173
+ },
174
+ });
175
+ return code;
176
+ }
177
+
178
+ parseArgs(fnName, ast) {
179
+ const functionArguments = {};
180
+
181
+ traverse(ast, {
182
+ FunctionDeclaration(path) {
183
+ if (path.node.id.name !== fnName) return;
184
+
185
+ path.node.params
186
+ .filter((node) => {
187
+ return node?.right?.type !== "ObjectExpression";
188
+ })
189
+ .forEach((param) => {
190
+ if (param.type === "AssignmentPattern") {
191
+ functionArguments[param.left.name] = param.right.value;
192
+ return;
193
+ }
194
+ if (!param.name) return;
195
+ functionArguments[param.name] = null;
196
+ });
197
+ },
198
+ });
199
+
200
+ return functionArguments;
201
+ }
202
+
203
+ parseOpts(fnName, ast) {
204
+ let functionOpts = {};
205
+
206
+ traverse(ast, {
207
+ FunctionDeclaration(path) {
208
+ if (path.node.id.name !== fnName) return;
209
+
210
+ const node = path.node.params.pop();
211
+ if (!node) return;
212
+ if (
213
+ !node.type === "AssignmentPattern" &&
214
+ node.right.type === "ObjectExpression"
215
+ )
216
+ return;
217
+
218
+ node?.right?.properties?.forEach((p) => {
219
+ if (
220
+ ["NumericLiteral", "StringLiteral", "BooleanLiteral"].includes(
221
+ p.value.type,
222
+ )
223
+ ) {
224
+ functionOpts[camelToDasherize(p.key.name)] = p.value.value;
225
+ return;
226
+ }
227
+
228
+ if (p.value.type === "NullLiteral") {
229
+ functionOpts[camelToDasherize(p.key.name)] = null;
230
+ return;
231
+ }
232
+
233
+ if (p.value.type == "UnaryExpression" && p.value.operator == "!") {
234
+ functionOpts[camelToDasherize(p.key.name)] =
235
+ !p.value.argument.value;
236
+ return;
237
+ }
238
+ });
239
+ },
240
+ });
241
+
242
+ return functionOpts;
243
+ }
244
+
245
+ prepareCommandName(name) {
246
+ name = name
247
+ .split(/(?=[A-Z])/)
248
+ .join("-")
249
+ .toLowerCase();
250
+ return name.replace("-", ":");
251
+ }
252
+
253
+
254
+ async handleRequest(req, server) {
255
+ const url = new URL(req.url);
256
+
257
+ if (url.pathname === '/ws') {
258
+ if (server.upgrade(req)) {
259
+ return;
260
+ }
261
+ return new Response('Upgrade failed', { status: 500 });
262
+ }
263
+
264
+ if (req.method === 'GET' && url.pathname === '/') {
265
+ return new Response(this.getIndexHTML(), {
266
+ headers: { 'Content-Type': 'text/html' }
267
+ });
268
+ } else if (req.method === 'GET' && url.pathname === '/api/commands') {
269
+ return new Response(JSON.stringify(this.commands), {
270
+ headers: { 'Content-Type': 'application/json' }
271
+ });
272
+ } else if (req.method === 'GET' && url.pathname === '/api/source') {
273
+ return new Response(JSON.stringify({ path: this.bunoshfilePath, source: this.bunoshSource }), {
274
+ headers: { 'Content-Type': 'application/json' }
275
+ });
276
+ } else if (req.method === 'POST' && url.pathname === '/api/execute') {
277
+ return await this.handleExecuteCommand(req);
278
+ } else {
279
+ return new Response('Not Found', { status: 404 });
280
+ }
281
+ }
282
+
283
+ async handleExecuteCommand(req) {
284
+ try {
285
+ const body = await req.text();
286
+ const { command, args, opts } = JSON.parse(body);
287
+
288
+ if (!this.commands[command]) {
289
+ return new Response(JSON.stringify({ error: 'Command not found' }), {
290
+ status: 400,
291
+ headers: { 'Content-Type': 'application/json' }
292
+ });
293
+ }
294
+
295
+ const executionId = Date.now().toString();
296
+
297
+ this.executeCommand(command, args, opts, executionId);
298
+
299
+ return new Response(JSON.stringify({ executionId, status: 'started' }), {
300
+ headers: { 'Content-Type': 'application/json' }
301
+ });
302
+ } catch (error) {
303
+ return new Response(JSON.stringify({ error: error.message }), {
304
+ status: 400,
305
+ headers: { 'Content-Type': 'application/json' }
306
+ });
307
+ }
308
+ }
309
+
310
+ async executeCommand(commandName, args, opts, executionId) {
311
+ const command = this.commands[commandName];
312
+
313
+ this.broadcast({
314
+ type: 'execution_start',
315
+ executionId,
316
+ command: commandName
317
+ });
318
+
319
+ const originalIsTTY = process.stdout.isTTY;
320
+
321
+ try {
322
+ await import('../index.js');
323
+
324
+ globalThis._bunoshUIMode = true;
325
+ globalThis._bunoshUIExecutionId = executionId;
326
+ globalThis._bunoshUIServer = this;
327
+
328
+ process.env.FORCE_COLOR = '1';
329
+ process.env.TERM = 'xterm-256color';
330
+
331
+ process.stdout.isTTY = true;
332
+ process.stderr.isTTY = true;
333
+
334
+ const originalConsoleLog = console.log;
335
+ const originalConsoleError = console.error;
336
+
337
+ console.log = (...args) => {
338
+ const rawMessage = args.join(' ');
339
+ const htmlMessage = this.ansiConverter.toHtml(rawMessage);
340
+ this.broadcast({
341
+ type: 'output',
342
+ executionId,
343
+ level: 'info',
344
+ message: rawMessage,
345
+ html: htmlMessage
346
+ });
347
+ originalConsoleLog(...args);
348
+ };
349
+
350
+ console.error = (...args) => {
351
+ const rawMessage = args.join(' ');
352
+ const htmlMessage = this.ansiConverter.toHtml(rawMessage);
353
+ this.broadcast({
354
+ type: 'output',
355
+ executionId,
356
+ level: 'error',
357
+ message: rawMessage,
358
+ html: htmlMessage
359
+ });
360
+ originalConsoleError(...args);
361
+ };
362
+
363
+ const result = await command.fn(...Object.values(args), opts);
364
+
365
+ console.log = originalConsoleLog;
366
+ console.error = originalConsoleError;
367
+
368
+ process.stdout.isTTY = originalIsTTY;
369
+ process.stderr.isTTY = originalIsTTY;
370
+
371
+ globalThis._bunoshUIMode = false;
372
+ globalThis._bunoshUIExecutionId = null;
373
+ globalThis._bunoshUIServer = null;
374
+
375
+ this.broadcast({
376
+ type: 'execution_complete',
377
+ executionId,
378
+ status: 'success',
379
+ result
380
+ });
381
+ } catch (error) {
382
+ process.stdout.isTTY = originalIsTTY;
383
+ process.stderr.isTTY = originalIsTTY;
384
+
385
+ globalThis._bunoshUIMode = false;
386
+ globalThis._bunoshUIExecutionId = null;
387
+ globalThis._bunoshUIServer = null;
388
+
389
+ this.broadcast({
390
+ type: 'execution_complete',
391
+ executionId,
392
+ status: 'error',
393
+ error: error.message
394
+ });
395
+ }
396
+ }
397
+
398
+ handleWebSocketMessage(ws, data) {
399
+ try {
400
+ const message = JSON.parse(data.toString());
401
+
402
+ if (message.type === 'ask_response' && this.pendingAskPromises.has(message.askId)) {
403
+ const resolve = this.pendingAskPromises.get(message.askId);
404
+ this.pendingAskPromises.delete(message.askId);
405
+ resolve(message.value);
406
+ }
407
+ } catch (error) {
408
+ console.error('WebSocket message error:', error);
409
+ }
410
+ }
411
+
412
+ broadcast(message) {
413
+ this.clients.forEach(client => {
414
+ client.send(JSON.stringify(message));
415
+ });
416
+ }
417
+
418
+ async askInUI(question, opts = {}) {
419
+ const askId = Date.now().toString() + Math.random();
420
+ const executionId = globalThis._bunoshUIExecutionId;
421
+
422
+ return new Promise((resolve) => {
423
+ this.pendingAskPromises.set(askId, resolve);
424
+
425
+ this.broadcast({
426
+ type: 'ask_prompt',
427
+ executionId,
428
+ askId,
429
+ question,
430
+ opts
431
+ });
432
+ });
433
+ }
434
+
435
+ getIndexHTML() {
436
+ let version = '';
437
+ try {
438
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
439
+ version = pkg.version;
440
+ } catch (e) {}
441
+
442
+ const cwd = process.cwd();
443
+ const home = process.env.HOME || '';
444
+ const cwdDisplay = (home && cwd.startsWith(home)) ? '~' + cwd.slice(home.length) : cwd;
445
+ const bunoshfileName = this.bunoshfilePath ? path.basename(this.bunoshfilePath) : 'Bunoshfile.js';
446
+ const meta = JSON.stringify({ cwd, cwdDisplay, file: bunoshfileName });
447
+
448
+ return `<!DOCTYPE html>
449
+ <html lang="en">
450
+ <head>
451
+ <meta charset="UTF-8">
452
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
453
+ <title>Bunosh</title>
454
+ <link rel="preconnect" href="https://fonts.googleapis.com">
455
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
456
+ <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
457
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
458
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
459
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
460
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
461
+ <style>
462
+ :root {
463
+ --bg: #100d09;
464
+ --surface: #1b1610;
465
+ --surface-2: #221c15;
466
+ --border: #2e2720;
467
+ --border-strong: #3b322a;
468
+ --text: #ece3d6;
469
+ --muted: #a99e8d;
470
+ --faint: #6f6557;
471
+ --accent: #f0a02e;
472
+ --accent-soft: rgba(240,160,46,.12);
473
+ --accent-line: rgba(240,160,46,.45);
474
+ --success: #8fb368;
475
+ --danger: #db5b39;
476
+ --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
477
+ --sans: 'Bricolage Grotesque', ui-sans-serif, system-ui, sans-serif;
478
+ }
479
+ * { box-sizing: border-box; }
480
+ html, body, #root { height: 100%; margin: 0; }
481
+ body {
482
+ background: var(--bg);
483
+ color: var(--text);
484
+ font-family: var(--mono);
485
+ font-size: 14px;
486
+ -webkit-font-smoothing: antialiased;
487
+ }
488
+ body::before {
489
+ content: "";
490
+ position: fixed;
491
+ inset: 0;
492
+ pointer-events: none;
493
+ z-index: 0;
494
+ background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><circle cx='1.5' cy='1.5' r='1' fill='%23f0a02e' fill-opacity='0.05'/></svg>");
495
+ background-size: 24px 24px;
496
+ }
497
+ #root { position: relative; z-index: 1; }
498
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
499
+ ::-webkit-scrollbar-thumb { background: #332b22; border-radius: 6px; }
500
+ ::-webkit-scrollbar-thumb:hover { background: #463b2e; }
501
+ ::-webkit-scrollbar-track { background: transparent; }
502
+
503
+ @keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
504
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
505
+ .reveal { opacity: 0; animation: fadeUp .5s cubic-bezier(.2,.7,.2,1) forwards; }
506
+ @media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; animation: none; } }
507
+
508
+ .app { height: 100%; display: flex; flex-direction: column; border-top: 3px solid var(--accent); }
509
+ .topbar { display: flex; align-items: center; justify-content: space-between; height: 60px; padding: 0 22px; border-bottom: 1px solid var(--border); background: var(--surface); }
510
+ .brand { display: flex; align-items: center; gap: 10px; }
511
+ .brand-mark { font-size: 24px; line-height: 1; }
512
+ .brand-name { font-family: var(--sans); font-weight: 700; font-size: 23px; letter-spacing: -.02em; }
513
+ .brand-ver { font-family: var(--mono); font-size: 11px; color: var(--accent); border: 1px solid var(--accent-line); border-radius: 999px; padding: 2px 8px; }
514
+ .status { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; }
515
+ .status .addr { color: var(--faint); text-transform: none; letter-spacing: 0; }
516
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); display: inline-block; flex-shrink: 0; }
517
+ .dot--running { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
518
+ .dot--success { background: var(--success); }
519
+ .dot--error, .dot--offline { background: var(--danger); }
520
+
521
+ .body { flex: 1; display: flex; min-height: 0; }
522
+ .sidebar { width: 282px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: #161109; }
523
+ .search { padding: 14px; border-bottom: 1px solid var(--border); }
524
+ .search-box { display: flex; align-items: center; gap: 8px; padding: 9px 11px; border: 1px solid var(--border-strong); border-radius: 10px; background: var(--bg); transition: border-color .15s; }
525
+ .search-box:focus-within { border-color: var(--accent-line); }
526
+ .search-box input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 13px; caret-color: var(--accent); }
527
+ .search-box input::placeholder { color: var(--faint); }
528
+ .search-box kbd { font-family: var(--mono); font-size: 10px; color: var(--faint); border: 1px solid var(--border-strong); border-radius: 5px; padding: 1px 5px; }
529
+ .cmd-list { flex: 1; overflow-y: auto; padding: 8px; }
530
+ .cmd { display: block; width: 100%; text-align: left; padding: 9px 12px; margin-bottom: 2px; border: none; border-left: 2px solid transparent; border-radius: 8px; background: transparent; cursor: pointer; transition: background .14s, border-color .14s; }
531
+ .cmd:hover { background: var(--accent-soft); }
532
+ .cmd.is-active { background: var(--accent-soft); border-left-color: var(--accent); }
533
+ .cmd-name { font-family: var(--mono); font-size: 13px; color: var(--text); }
534
+ .cmd.is-active .cmd-name { color: var(--accent); }
535
+ .cmd-desc { font-size: 11px; color: var(--faint); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
536
+ .sidebar-foot { padding: 10px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--faint); letter-spacing: .04em; }
537
+ .no-match { padding: 28px 12px; text-align: center; color: var(--faint); font-size: 13px; }
538
+
539
+ .main { flex: 1; min-width: 0; overflow-y: auto; }
540
+ .detail { max-width: 760px; margin: 0 auto; padding: 34px 28px; }
541
+ .cmd-title { font-family: var(--mono); font-size: 24px; font-weight: 500; color: var(--text); letter-spacing: -.01em; margin: 0; }
542
+ .cmd-sub { font-family: var(--sans); font-size: 16px; color: var(--muted); margin: 7px 0 0; }
543
+ .panel { margin-top: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); padding: 20px; box-shadow: 0 18px 40px -28px rgba(0,0,0,.85); }
544
+ .field + .field { margin-top: 16px; }
545
+ .field-label { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; font-family: var(--mono); font-size: 13px; color: var(--accent); }
546
+ .badge { font-size: 9px; text-transform: uppercase; letter-spacing: .1em; padding: 2px 6px; border-radius: 5px; }
547
+ .badge--req { color: var(--danger); background: rgba(219,91,57,.14); }
548
+ .badge--opt { color: var(--muted); background: rgba(169,158,141,.14); }
549
+ .input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-strong); border-radius: 9px; background: var(--bg); color: var(--text); font-family: var(--mono); font-size: 13px; outline: none; transition: border-color .15s; }
550
+ .input:focus { border-color: var(--accent-line); }
551
+ .input::placeholder { color: var(--faint); }
552
+ .check { display: flex; align-items: center; gap: 9px; cursor: pointer; user-select: none; font-family: var(--mono); font-size: 13px; color: var(--accent); }
553
+ .check input { width: 16px; height: 16px; accent-color: var(--accent); }
554
+ .no-args { color: var(--faint); font-size: 13px; margin-top: 20px; }
555
+ .actions { display: flex; align-items: center; gap: 12px; margin-top: 20px; }
556
+ .btn { font-family: var(--mono); font-weight: 500; font-size: 13px; letter-spacing: .02em; padding: 11px 18px; border-radius: 10px; border: 1px solid transparent; cursor: pointer; transition: transform .12s, background .15s, color .15s, border-color .15s; }
557
+ .btn--primary { background: var(--accent); color: #1a1206; box-shadow: 0 1px 0 rgba(255,255,255,.18) inset, 0 12px 26px -14px rgba(240,160,46,.7); }
558
+ .btn--primary:hover { background: #ffb648; transform: translateY(-1px); }
559
+ .btn--primary:active { transform: translateY(0); }
560
+ .btn--primary:disabled { background: var(--surface-2); color: var(--faint); box-shadow: none; cursor: not-allowed; transform: none; }
561
+ .btn--ghost { background: transparent; color: var(--muted); border-color: var(--border-strong); }
562
+ .btn--ghost:hover { color: var(--text); background: var(--accent-soft); border-color: var(--accent-line); }
563
+
564
+ .output { margin-top: 24px; border: 1px solid var(--border); border-radius: 14px; overflow: hidden; background: #0b0a07; }
565
+ .output-head { display: flex; align-items: center; justify-content: space-between; padding: 9px 16px; background: var(--surface); border-bottom: 1px solid var(--border); }
566
+ .output-head .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
567
+ .output-status { display: flex; align-items: center; gap: 7px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
568
+ .output-body { padding: 16px; font-family: var(--mono); font-size: 13px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; overflow-y: auto; max-height: 56vh; color: #e7ddd0; }
569
+
570
+ .empty { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 24px; }
571
+ .empty-mark { font-size: 54px; margin-bottom: 18px; }
572
+ .empty h2 { font-family: var(--sans); font-weight: 600; font-size: 26px; color: var(--text); margin: 0; letter-spacing: -.01em; }
573
+ .empty p { font-family: var(--sans); color: var(--muted); max-width: 390px; margin: 11px 0 0; font-size: 15px; line-height: 1.55; }
574
+ .empty .hint { margin-top: 20px; font-size: 12px; color: var(--faint); }
575
+ .empty kbd { font-family: var(--mono); border: 1px solid var(--border-strong); border-radius: 5px; padding: 1px 6px; color: var(--muted); }
576
+
577
+ .modal-backdrop { position: fixed; inset: 0; background: rgba(8,6,3,.7); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; z-index: 50; padding: 16px; }
578
+ .modal { width: 100%; max-width: 440px; border: 1px solid var(--border-strong); border-radius: 18px; background: var(--surface); padding: 24px; box-shadow: 0 30px 60px -20px rgba(0,0,0,.85); }
579
+ .modal-title { font-family: var(--sans); font-weight: 600; font-size: 19px; color: var(--text); margin: 0 0 18px; }
580
+ .modal-row { display: flex; gap: 12px; }
581
+ .modal-row .btn { flex: 1; }
582
+ .choices { display: flex; flex-direction: column; gap: 8px; }
583
+ .choice { font-family: var(--mono); font-size: 13px; text-align: left; padding: 11px 14px; border: 1px solid var(--border-strong); border-radius: 10px; background: var(--bg); color: var(--text); cursor: pointer; transition: border-color .15s, background .15s; }
584
+ .choice:hover { border-color: var(--accent-line); background: var(--accent-soft); }
585
+ .modal .input { margin-bottom: 16px; }
586
+
587
+ .meta { display: flex; align-items: center; gap: 14px; min-width: 0; font-size: 12px; color: var(--muted); }
588
+ .meta-cwd { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; max-width: 40ch; }
589
+ .meta-file { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-line); border-radius: 999px; padding: 3px 11px; cursor: pointer; transition: background .15s; white-space: nowrap; flex-shrink: 0; }
590
+ .meta-file:hover { background: rgba(240,160,46,.2); }
591
+
592
+ .detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
593
+ .detail-head-text { min-width: 0; }
594
+ .btn--sm { padding: 7px 13px; font-size: 12px; }
595
+ .code-toggle { flex-shrink: 0; }
596
+ .code-block { margin-top: 20px; padding: 16px 18px; border: 1px solid var(--border); border-radius: 12px; background: #0b0a07; color: #e7ddd0; font-family: var(--mono); font-size: 12.5px; line-height: 1.6; overflow: auto; max-height: 440px; white-space: pre; tab-size: 2; }
597
+
598
+ .modal-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
599
+ .modal-close { background: transparent; border: none; color: var(--muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 4px 6px; border-radius: 6px; }
600
+ .modal-close:hover { color: var(--text); background: var(--accent-soft); }
601
+ .modal--code { max-width: 820px; }
602
+ .modal--code .code-block { margin-top: 0; max-height: 70vh; }
603
+
604
+ .code-block code.hljs { background: transparent; padding: 0; color: #e7ddd0; }
605
+ .hljs-comment, .hljs-quote { color: #6f6557; font-style: italic; }
606
+ .hljs-keyword, .hljs-literal, .hljs-selector-tag, .hljs-section, .hljs-doctag, .hljs-name { color: #f0a02e; }
607
+ .hljs-title, .hljs-title.function_, .hljs-title.class_ { color: #f3cf8c; }
608
+ .hljs-string, .hljs-regexp, .hljs-meta .hljs-string, .hljs-addition { color: #9ec27a; }
609
+ .hljs-number, .hljs-symbol, .hljs-bullet { color: #e08a5a; }
610
+ .hljs-built_in, .hljs-type, .hljs-builtin-name { color: #d8a85e; }
611
+ .hljs-attr, .hljs-attribute, .hljs-property, .hljs-variable, .hljs-template-variable, .hljs-params { color: #cdbfa6; }
612
+ .hljs-operator, .hljs-punctuation { color: #a99e8d; }
613
+ .hljs-emphasis { font-style: italic; }
614
+ .hljs-strong { font-weight: 700; }
615
+
616
+ .shortcuts { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 8px 22px; border-top: 1px solid var(--border); background: var(--surface); font-size: 11px; color: var(--faint); flex-shrink: 0; }
617
+ .shortcuts .sc { display: flex; align-items: center; gap: 5px; }
618
+ .shortcuts kbd { font-family: var(--mono); font-size: 10px; color: var(--muted); background: var(--bg); border: 1px solid var(--border-strong); border-radius: 5px; padding: 1px 6px; }
619
+ </style>
620
+ </head>
621
+ <body>
622
+ <div id="root"></div>
623
+ <script type="text/babel" data-type="module">
624
+ const { useState, useEffect, useRef } = React;
625
+
626
+ const META = ${meta};
627
+
628
+ function CodeBlock({ code }) {
629
+ const hl = (window.hljs && code) ? window.hljs.highlight(code, { language: "javascript" }).value : null;
630
+ if (hl) {
631
+ return <pre className="code-block"><code className="hljs" dangerouslySetInnerHTML={{ __html: hl }} /></pre>;
632
+ }
633
+ return <pre className="code-block"><code className="hljs">{code}</code></pre>;
634
+ }
635
+
636
+ function AskModal({ prompt, onAnswer }) {
637
+ const [value, setValue] = useState(prompt.opts.default || "");
638
+ const isConfirm = prompt.opts.type === "confirm";
639
+ const choices = prompt.opts.choices;
640
+ const submit = (e) => { e.preventDefault(); onAnswer(value); };
641
+
642
+ return (
643
+ <div className="modal-backdrop">
644
+ <div className="modal">
645
+ <h3 className="modal-title">{prompt.question}</h3>
646
+ {isConfirm ? (
647
+ <div className="modal-row">
648
+ <button className="btn btn--primary" onClick={() => onAnswer(true)}>Yes</button>
649
+ <button className="btn btn--ghost" onClick={() => onAnswer(false)}>No</button>
650
+ </div>
651
+ ) : choices ? (
652
+ <div className="choices">
653
+ {choices.map((ch, i) => (
654
+ <button key={i} className="choice" onClick={() => onAnswer(ch)}>{ch}</button>
655
+ ))}
656
+ </div>
657
+ ) : (
658
+ <form onSubmit={submit}>
659
+ <input
660
+ className="input"
661
+ autoFocus
662
+ value={value}
663
+ onChange={(e) => setValue(e.target.value)}
664
+ placeholder={prompt.opts.default || "Type your answer…"}
665
+ />
666
+ <div className="modal-row">
667
+ <button type="submit" className="btn btn--primary">Submit</button>
668
+ <button type="button" className="btn btn--ghost" onClick={() => onAnswer(prompt.opts.default || "")}>Cancel</button>
669
+ </div>
670
+ </form>
671
+ )}
672
+ </div>
673
+ </div>
674
+ );
675
+ }
676
+
677
+ function App() {
678
+ const [commands, setCommands] = useState({});
679
+ const [query, setQuery] = useState("");
680
+ const [selected, setSelected] = useState(null);
681
+ const [formData, setFormData] = useState({});
682
+ const [output, setOutput] = useState("");
683
+ const [isExecuting, setIsExecuting] = useState(false);
684
+ const [runStatus, setRunStatus] = useState("idle");
685
+ const [connected, setConnected] = useState(false);
686
+ const [askPrompt, setAskPrompt] = useState(null);
687
+ const [showCode, setShowCode] = useState(false);
688
+ const [fileOpen, setFileOpen] = useState(false);
689
+ const [fileSource, setFileSource] = useState("");
690
+ const wsRef = useRef(null);
691
+ const outRef = useRef(null);
692
+ const searchRef = useRef(null);
693
+ const listRef = useRef(null);
694
+ const mainRef = useRef(null);
695
+ const pendingFocusRef = useRef(false);
696
+
697
+ function onMessage(m) {
698
+ if (m.type === "output") {
699
+ setOutput((prev) => prev + (m.html || m.message) + "\\n");
700
+ } else if (m.type === "execution_start") {
701
+ setIsExecuting(true);
702
+ setRunStatus("running");
703
+ setOutput("");
704
+ } else if (m.type === "execution_complete") {
705
+ setIsExecuting(false);
706
+ setRunStatus(m.status === "error" ? "error" : "success");
707
+ setAskPrompt(null);
708
+ } else if (m.type === "ask_prompt") {
709
+ setAskPrompt(m);
710
+ }
711
+ }
712
+
713
+ useEffect(() => {
714
+ fetch("/api/commands").then((r) => r.json()).then(setCommands);
715
+ let socket, timer, closed = false;
716
+ const connect = () => {
717
+ socket = new WebSocket("ws://" + window.location.host + "/ws");
718
+ socket.onopen = () => setConnected(true);
719
+ socket.onclose = () => { setConnected(false); if (!closed) timer = setTimeout(connect, 1500); };
720
+ socket.onmessage = (e) => onMessage(JSON.parse(e.data));
721
+ wsRef.current = socket;
722
+ };
723
+ connect();
724
+ return () => { closed = true; clearTimeout(timer); if (socket) socket.close(); };
725
+ }, []);
726
+
727
+ useEffect(() => {
728
+ if (!selected || !listRef.current) return;
729
+ const active = listRef.current.querySelector(".cmd.is-active");
730
+ if (active) {
731
+ active.scrollIntoView({ block: "nearest" });
732
+ if (pendingFocusRef.current) { active.focus(); pendingFocusRef.current = false; }
733
+ }
734
+ }, [selected]);
735
+
736
+ useEffect(() => {
737
+ if (outRef.current) outRef.current.scrollTop = outRef.current.scrollHeight;
738
+ }, [output]);
739
+
740
+ const q = query.trim().toLowerCase();
741
+ const list = Object.values(commands).filter((c) => {
742
+ if (!q) return true;
743
+ return c.name.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q);
744
+ });
745
+ const total = Object.keys(commands).length;
746
+
747
+ const select = (c) => { setSelected(c); setFormData({}); setOutput(""); setRunStatus("idle"); setShowCode(false); };
748
+
749
+ const run = (e) => {
750
+ if (e) e.preventDefault();
751
+ if (!selected || isExecuting) return;
752
+ const args = {};
753
+ const opts = {};
754
+ Object.keys(selected.args).forEach((k) => {
755
+ args[k] = (formData[k] !== undefined && formData[k] !== "") ? formData[k] : selected.args[k];
756
+ });
757
+ Object.keys(selected.opts).forEach((k) => {
758
+ opts[k] = formData[k] !== undefined ? formData[k] : selected.opts[k];
759
+ });
760
+ fetch("/api/execute", {
761
+ method: "POST",
762
+ headers: { "Content-Type": "application/json" },
763
+ body: JSON.stringify({ command: selected.name, args, opts })
764
+ }).catch((err) => setOutput((prev) => prev + "Error: " + err.message + "\\n"));
765
+ };
766
+
767
+ const answer = (value) => {
768
+ if (askPrompt && wsRef.current) {
769
+ wsRef.current.send(JSON.stringify({ type: "ask_response", askId: askPrompt.askId, value }));
770
+ setAskPrompt(null);
771
+ }
772
+ };
773
+
774
+ const clear = () => { setOutput(""); setRunStatus("idle"); };
775
+
776
+ const openFile = () => {
777
+ setFileOpen(true);
778
+ if (!fileSource) {
779
+ fetch("/api/source").then((r) => r.json()).then((d) => setFileSource(d.source || ""));
780
+ }
781
+ };
782
+
783
+ const hasParams = selected && (Object.keys(selected.args).length > 0 || Object.keys(selected.opts).length > 0);
784
+ const headStatus = connected ? (runStatus === "idle" ? "ready" : runStatus) : "offline";
785
+
786
+ useEffect(() => {
787
+ const focusMain = () => {
788
+ const main = mainRef.current;
789
+ const target = main && (main.querySelector(".input") || main.querySelector(".check input") || main.querySelector(".btn--primary"));
790
+ if (target) target.focus();
791
+ };
792
+ const focusList = () => {
793
+ const listEl = listRef.current;
794
+ const target = listEl && (listEl.querySelector(".cmd.is-active") || listEl.querySelector(".cmd"));
795
+ if (target) target.focus();
796
+ };
797
+ const onKey = (e) => {
798
+ if (e.key === "Escape" && fileOpen) { setFileOpen(false); return; }
799
+
800
+ const el = document.activeElement;
801
+ const inField = el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA");
802
+ const inOtherField = inField && el !== searchRef.current;
803
+ const noMod = !e.ctrlKey && !e.metaKey && !e.altKey;
804
+
805
+ if (e.key === "/" && noMod) {
806
+ if (inOtherField) return;
807
+ e.preventDefault();
808
+ if (searchRef.current) { searchRef.current.focus(); searchRef.current.select(); }
809
+ return;
810
+ }
811
+
812
+ if (e.key === "ArrowRight" && (e.ctrlKey || (noMod && !inField))) {
813
+ e.preventDefault();
814
+ focusMain();
815
+ return;
816
+ }
817
+
818
+ if (e.key === "ArrowLeft" && (e.ctrlKey || (noMod && !inField))) {
819
+ e.preventDefault();
820
+ focusList();
821
+ return;
822
+ }
823
+
824
+ if ((e.key === "ArrowDown" || e.key === "ArrowUp") && noMod) {
825
+ if (askPrompt || list.length === 0) return;
826
+ if (inOtherField) return;
827
+ if (mainRef.current && mainRef.current.contains(el)) return;
828
+ e.preventDefault();
829
+ const idx = selected ? list.findIndex((c) => c.name === selected.name) : -1;
830
+ let next = e.key === "ArrowDown" ? idx + 1 : idx - 1;
831
+ if (next < 0) next = list.length - 1;
832
+ if (next >= list.length) next = 0;
833
+ pendingFocusRef.current = !!(el && el.classList && el.classList.contains("cmd"));
834
+ select(list[next]);
835
+ }
836
+ };
837
+ window.addEventListener("keydown", onKey);
838
+ return () => window.removeEventListener("keydown", onKey);
839
+ }, [list, selected, askPrompt, fileOpen]);
840
+
841
+ return (
842
+ <div className="app">
843
+ <header className="topbar reveal">
844
+ <div className="brand">
845
+ <span className="brand-mark">🍲</span>
846
+ <span className="brand-name">Bunosh</span>
847
+ <span className="brand-ver">v${version}</span>
848
+ </div>
849
+ <div className="meta">
850
+ <span className="meta-cwd" title={META.cwd}>📁 {META.cwdDisplay}</span>
851
+ <button className="meta-file" onClick={openFile} title="View Bunoshfile source">📄 {META.file}</button>
852
+ </div>
853
+ <div className="status">
854
+ <span className={"dot dot--" + (connected ? runStatus : "offline")}></span>
855
+ <span>{headStatus}</span>
856
+ <span className="addr">{window.location.host}</span>
857
+ </div>
858
+ </header>
859
+
860
+ <div className="body">
861
+ <aside className="sidebar reveal" style={{ animationDelay: "60ms" }}>
862
+ <div className="search">
863
+ <div className="search-box">
864
+ <span>🔍</span>
865
+ <input
866
+ ref={searchRef}
867
+ value={query}
868
+ onChange={(e) => setQuery(e.target.value)}
869
+ placeholder="Search commands…"
870
+ autoFocus
871
+ />
872
+ <kbd>/</kbd>
873
+ </div>
874
+ </div>
875
+ <div className="cmd-list" ref={listRef}>
876
+ {list.map((c, i) => (
877
+ <button
878
+ key={c.name}
879
+ className={"cmd reveal" + (selected && selected.name === c.name ? " is-active" : "")}
880
+ style={{ animationDelay: (Math.min(i, 18) * 22) + "ms" }}
881
+ onClick={() => select(c)}
882
+ >
883
+ <div className="cmd-name">{c.name}</div>
884
+ {c.description ? <div className="cmd-desc">{c.description}</div> : null}
885
+ </button>
886
+ ))}
887
+ {list.length === 0 ? <div className="no-match">No commands match “{query}”.</div> : null}
888
+ </div>
889
+ <div className="sidebar-foot">
890
+ {list.length === total ? total + " commands" : list.length + " / " + total + " commands"}
891
+ </div>
892
+ </aside>
893
+
894
+ <main className="main" ref={mainRef}>
895
+ {selected ? (
896
+ <div className="detail reveal" key={selected.name}>
897
+ <div className="detail-head">
898
+ <div className="detail-head-text">
899
+ <h1 className="cmd-title">{selected.name}</h1>
900
+ {selected.description ? <p className="cmd-sub">{selected.description}</p> : null}
901
+ </div>
902
+ {selected.code ? (
903
+ <button type="button" className="btn btn--ghost btn--sm code-toggle" onClick={() => setShowCode((s) => !s)}>
904
+ {showCode ? "Hide code" : "Show code"}
905
+ </button>
906
+ ) : null}
907
+ </div>
908
+
909
+ {showCode && selected.code ? <CodeBlock code={selected.code} /> : null}
910
+
911
+ <form onSubmit={run}>
912
+ {hasParams ? (
913
+ <div className="panel">
914
+ {Object.entries(selected.args).map(([key, def]) => (
915
+ <div className="field" key={key}>
916
+ <label className="field-label">
917
+ <span>{key}</span>
918
+ <span className={"badge " + (def === null ? "badge--req" : "badge--opt")}>{def === null ? "required" : "optional"}</span>
919
+ </label>
920
+ <input
921
+ className="input"
922
+ type="text"
923
+ placeholder={(def !== null && def !== undefined) ? String(def) : "Enter " + key}
924
+ value={formData[key] || ""}
925
+ onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
926
+ />
927
+ </div>
928
+ ))}
929
+ {Object.entries(selected.opts).map(([key, def]) => (
930
+ <div className="field" key={key}>
931
+ {typeof def === "boolean" ? (
932
+ <label className="check">
933
+ <input
934
+ type="checkbox"
935
+ checked={formData[key] !== undefined ? formData[key] : def}
936
+ onChange={(e) => setFormData({ ...formData, [key]: e.target.checked })}
937
+ />
938
+ <span>--{key}</span>
939
+ </label>
940
+ ) : (
941
+ <div>
942
+ <label className="field-label"><span>--{key}</span></label>
943
+ <input
944
+ className="input"
945
+ type="text"
946
+ placeholder={(def !== null && def !== undefined) ? String(def) : "Enter " + key}
947
+ value={formData[key] || ""}
948
+ onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
949
+ />
950
+ </div>
951
+ )}
952
+ </div>
953
+ ))}
954
+ </div>
955
+ ) : (
956
+ <p className="no-args">This command takes no arguments.</p>
957
+ )}
958
+
959
+ <div className="actions">
960
+ <button type="submit" className="btn btn--primary" disabled={isExecuting}>
961
+ {isExecuting ? "Running…" : "▶ Launch"}
962
+ </button>
963
+ {output ? <button type="button" className="btn btn--ghost" onClick={clear}>Clear</button> : null}
964
+ </div>
965
+ </form>
966
+
967
+ {(output || isExecuting) ? (
968
+ <div className="output">
969
+ <div className="output-head">
970
+ <span className="lbl">Output</span>
971
+ <span className="output-status">
972
+ <span className={"dot dot--" + runStatus}></span>
973
+ {runStatus}
974
+ </span>
975
+ </div>
976
+ <div className="output-body" ref={outRef} dangerouslySetInnerHTML={{ __html: output || "" }} />
977
+ </div>
978
+ ) : null}
979
+ </div>
980
+ ) : (
981
+ <div className="empty">
982
+ <div className="empty-mark">🍲</div>
983
+ <h2>Pick a command to run</h2>
984
+ <p>Search and select a task, fill in its arguments, and hit Launch. Output streams right below.</p>
985
+ <div className="hint">Press <kbd>/</kbd> to search</div>
986
+ </div>
987
+ )}
988
+ </main>
989
+ </div>
990
+
991
+ <footer className="shortcuts">
992
+ <span className="sc"><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
993
+ <span className="sc"><kbd>→</kbd> open</span>
994
+ <span className="sc"><kbd>←</kbd> back</span>
995
+ <span className="sc"><kbd>Ctrl</kbd>+<kbd>←</kbd>/<kbd>→</kbd> jump panes</span>
996
+ <span className="sc"><kbd>/</kbd> search</span>
997
+ <span className="sc"><kbd>Enter</kbd> run</span>
998
+ <span className="sc"><kbd>Esc</kbd> close</span>
999
+ </footer>
1000
+
1001
+ {askPrompt ? <AskModal prompt={askPrompt} onAnswer={answer} /> : null}
1002
+
1003
+ {fileOpen ? (
1004
+ <div className="modal-backdrop" onClick={() => setFileOpen(false)}>
1005
+ <div className="modal modal--code" onClick={(e) => e.stopPropagation()}>
1006
+ <div className="modal-head">
1007
+ <h3 className="modal-title">📄 {META.file}</h3>
1008
+ <button className="modal-close" onClick={() => setFileOpen(false)} aria-label="Close">✕</button>
1009
+ </div>
1010
+ {fileSource ? <CodeBlock code={fileSource} /> : <pre className="code-block"><code className="hljs">Loading…</code></pre>}
1011
+ </div>
1012
+ </div>
1013
+ ) : null}
1014
+ </div>
1015
+ );
1016
+ }
1017
+
1018
+ ReactDOM.render(<App />, document.getElementById("root"));
1019
+ </script>
1020
+ </body>
1021
+ </html>`;
1022
+ }
1023
+ }
1024
+
1025
+ export default BunoshWebServer;
package/src/task.js CHANGED
@@ -99,10 +99,21 @@ export function getTaskPrefix(taskId) {
99
99
 
100
100
  export function createTaskInfo(name, parentId = null, isSilent = false) {
101
101
  const taskInfo = new TaskInfo(name, Date.now(), TaskStatus.RUNNING, parentId, isSilent);
102
+
103
+ let p = parentId;
104
+ while (p) {
105
+ const parent = runningTasks.get(p);
106
+ if (!parent) break;
107
+ if (parent.isTry || parent.isInsideTry) {
108
+ taskInfo.isInsideTry = true;
109
+ break;
110
+ }
111
+ p = parent.parentId;
112
+ }
113
+
102
114
  runningTasks.set(taskInfo.id, taskInfo);
103
115
  tasksExecuted.push(taskInfo);
104
116
 
105
- // Also add to global array for exit handler
106
117
  if (globalThis._bunoshGlobalTasksExecuted) {
107
118
  globalThis._bunoshGlobalTasksExecuted.push(taskInfo);
108
119
  }
@@ -114,10 +125,19 @@ export function finishTaskInfo(taskInfo, success = true, error = null, output =
114
125
  const endTime = Date.now();
115
126
  const duration = endTime - taskInfo.startTime;
116
127
 
117
- taskInfo.status = success ? TaskStatus.SUCCESS : TaskStatus.FAIL;
128
+ let status;
129
+ if (success) {
130
+ status = TaskStatus.SUCCESS;
131
+ } else if (taskInfo.isInsideTry) {
132
+ status = TaskStatus.WARNING;
133
+ } else {
134
+ status = TaskStatus.FAIL;
135
+ }
136
+
137
+ taskInfo.status = status;
118
138
  taskInfo.duration = duration;
119
139
  taskInfo.result = {
120
- status: success ? TaskStatus.SUCCESS : TaskStatus.FAIL,
140
+ status,
121
141
  output: error?.message || output || null
122
142
  };
123
143
 
@@ -132,6 +152,8 @@ export class TaskInfo {
132
152
  this.status = status;
133
153
  this.parentId = parentId;
134
154
  this.isSilent = isSilent;
155
+ this.isTry = false;
156
+ this.isInsideTry = false;
135
157
  }
136
158
  }
137
159
 
@@ -141,55 +163,60 @@ export async function tryTask(name, fn, isSilent = true) {
141
163
  name = fn.toString().slice(0, 50).replace(/\s+/g, ' ').trim();
142
164
  }
143
165
 
144
- const taskInfo = createTaskInfo(name, null, isSilent);
166
+ const taskInfo = createTaskInfo(name, getCurrentTaskId() || null, isSilent);
167
+ taskInfo.isTry = true;
168
+ const startIndex = tasksExecuted.length;
145
169
 
146
170
  const shouldPrint = !globalSilenceMode && !isSilent;
147
171
  const printer = new Printer('task', taskInfo.id);
148
172
  if (shouldPrint) printer.start(name);
149
173
 
174
+ let result;
175
+ let caughtError = null;
150
176
  try {
151
- const result = await asyncLocalStorage.run(taskInfo.id, async () => {
177
+ result = await asyncLocalStorage.run(taskInfo.id, async () => {
152
178
  return await Promise.resolve(fn());
153
179
  });
180
+ } catch (err) {
181
+ caughtError = err;
182
+ }
154
183
 
155
- const endTime = Date.now();
156
- const duration = endTime - taskInfo.startTime;
184
+ const endTime = Date.now();
185
+ const duration = endTime - taskInfo.startTime;
157
186
 
158
- // Check if result is a TaskResult and if it has failed
159
- if (result && typeof result === 'object' && result.constructor && result.constructor.name === 'TaskResult') {
160
- if (result.hasFailed || result.hasWarning) {
161
- taskInfo.status = TaskStatus.WARNING;
162
- taskInfo.duration = duration;
163
- taskInfo.result = { status: TaskStatus.WARNING, output: result.output };
187
+ let failed = caughtError !== null;
164
188
 
165
- if (shouldPrint) printer.warning(name);
166
- runningTasks.delete(taskInfo.id);
189
+ if (!failed && result && typeof result === 'object' && result.constructor && result.constructor.name === 'TaskResult') {
190
+ if (result.hasFailed || result.hasWarning) failed = true;
191
+ }
167
192
 
168
- return false;
193
+ if (!failed) {
194
+ for (let i = startIndex; i < tasksExecuted.length; i++) {
195
+ const t = tasksExecuted[i];
196
+ if (t === taskInfo) continue;
197
+ const s = t.result?.status;
198
+ if (s === TaskStatus.FAIL || s === TaskStatus.WARNING) {
199
+ failed = true;
200
+ break;
169
201
  }
170
202
  }
203
+ }
171
204
 
172
- taskInfo.status = TaskStatus.SUCCESS;
173
- taskInfo.duration = duration;
174
- taskInfo.result = { status: TaskStatus.SUCCESS, output: result };
175
-
176
- if (shouldPrint) printer.finish(name);
177
- runningTasks.delete(taskInfo.id);
178
-
179
- return true;
180
- } catch (err) {
181
- const endTime = Date.now();
182
- const duration = endTime - taskInfo.startTime;
183
-
205
+ if (failed) {
184
206
  taskInfo.status = TaskStatus.WARNING;
185
207
  taskInfo.duration = duration;
186
- taskInfo.result = { status: TaskStatus.WARNING, output: err.message };
187
-
188
- if (shouldPrint) printer.warning(name, err);
208
+ taskInfo.result = { status: TaskStatus.WARNING, output: caughtError?.message || result?.output || null };
209
+ if (shouldPrint) printer.warning(name, caughtError);
189
210
  runningTasks.delete(taskInfo.id);
190
-
191
211
  return false;
192
212
  }
213
+
214
+ taskInfo.status = TaskStatus.SUCCESS;
215
+ taskInfo.duration = duration;
216
+ taskInfo.result = { status: TaskStatus.SUCCESS, output: result };
217
+ if (shouldPrint) printer.finish(name);
218
+ runningTasks.delete(taskInfo.id);
219
+ return true;
193
220
  }
194
221
 
195
222
  export async function task(name, fn, isSilent = false) {
@@ -198,7 +225,7 @@ export async function task(name, fn, isSilent = false) {
198
225
  name = fn.toString().slice(0, 50).replace(/\s+/g, ' ').trim();
199
226
  }
200
227
 
201
- const taskInfo = createTaskInfo(name, null, isSilent);
228
+ const taskInfo = createTaskInfo(name, getCurrentTaskId() || null, isSilent);
202
229
 
203
230
  const shouldPrint = !globalSilenceMode && !isSilent;
204
231
  const printer = new Printer('task', taskInfo.id);
@@ -231,6 +258,15 @@ export async function task(name, fn, isSilent = false) {
231
258
  const endTime = Date.now();
232
259
  const duration = endTime - taskInfo.startTime;
233
260
 
261
+ if (taskInfo.isInsideTry) {
262
+ taskInfo.status = TaskStatus.WARNING;
263
+ taskInfo.duration = duration;
264
+ taskInfo.result = { status: TaskStatus.WARNING, output: err.message };
265
+ printer.warning(name, err);
266
+ runningTasks.delete(taskInfo.id);
267
+ return TaskResult.fail(err.message, { taskType: 'task', error: err });
268
+ }
269
+
234
270
  taskInfo.status = TaskStatus.FAIL;
235
271
  taskInfo.duration = duration;
236
272
  taskInfo.result = { status: TaskStatus.FAIL, output: err.message };
@@ -247,7 +283,7 @@ export async function task(name, fn, isSilent = false) {
247
283
  if (stopFailToggle && !isTestEnvironment) {
248
284
  process.exit(1);
249
285
  }
250
-
286
+
251
287
  return TaskResult.fail(err.message, { taskType: 'task', error: err });
252
288
  }
253
289
  }
@@ -16,6 +16,13 @@ export default function assert(condition, message = 'Assertion failed') {
16
16
  }
17
17
 
18
18
  const error = new Error(message);
19
+
20
+ if (taskInfo.isInsideTry) {
21
+ printer.warning(message, error);
22
+ finishTaskInfo(taskInfo, false, error, message);
23
+ return;
24
+ }
25
+
19
26
  printer.error(message, error);
20
27
  finishTaskInfo(taskInfo, false, error, message);
21
28