bunosh 0.5.9 β 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/bunosh.js +33 -0
- package/package.json +2 -1
- package/src/formatters/github-actions.js +1 -7
- package/src/io.js +8 -0
- package/src/program.js +14 -0
- package/src/server.js +1025 -0
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.
|
|
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
|
-
|
|
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;
|