@ubean/devtools 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-BLUNHNMx.js +384 -0
- package/dist/client/assets/index-BlZj1Onv.css +1 -0
- package/dist/client/index.html +15 -0
- package/dist/index.d.ts +561 -0
- package/dist/index.js +1894 -0
- package/package.json +2 -2
package/dist/index.js
ADDED
|
@@ -0,0 +1,1894 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createHooks } from "hookable";
|
|
5
|
+
import { execSync, spawn } from "node:child_process";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
//#region src/shared/env.ts
|
|
8
|
+
const SENSITIVE_KEY_FRAGMENTS = [
|
|
9
|
+
"KEY",
|
|
10
|
+
"SECRET",
|
|
11
|
+
"TOKEN",
|
|
12
|
+
"PASSWORD",
|
|
13
|
+
"AUTH",
|
|
14
|
+
"CREDENTIAL"
|
|
15
|
+
];
|
|
16
|
+
function isSensitiveKey(key) {
|
|
17
|
+
const upper = key.toUpperCase();
|
|
18
|
+
return SENSITIVE_KEY_FRAGMENTS.some((fragment) => upper.includes(fragment));
|
|
19
|
+
}
|
|
20
|
+
function maskSensitiveEnv(env) {
|
|
21
|
+
return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, isSensitiveKey(key) ? "***" : value]));
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/server/hooks.ts
|
|
25
|
+
function createDevToolsHooks() {
|
|
26
|
+
const hooks = createHooks();
|
|
27
|
+
function registerHook(type, handler) {
|
|
28
|
+
hooks.hook(type, handler);
|
|
29
|
+
}
|
|
30
|
+
async function runHook(type, ctx) {
|
|
31
|
+
await hooks.callHook(type, ctx);
|
|
32
|
+
}
|
|
33
|
+
function removeHook(type, handler) {
|
|
34
|
+
hooks.removeHook(type, handler);
|
|
35
|
+
}
|
|
36
|
+
function removeAllHooks() {
|
|
37
|
+
hooks.removeAllHooks();
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
registerHook,
|
|
41
|
+
runHook,
|
|
42
|
+
removeHook,
|
|
43
|
+
removeAllHooks,
|
|
44
|
+
hook: hooks.hook,
|
|
45
|
+
callHook: hooks.callHook
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const DEEPSEEK_PROVIDER_NAME = "deepseek";
|
|
49
|
+
const SYSTEM_PROMPT = `You are ubean Assistant, an AI developer assistant built into the ubean Vue meta-framework DevTools.
|
|
50
|
+
You help developers scaffold pages, APIs, middleware, layouts, cron jobs, plugins, and manage environment variables.
|
|
51
|
+
You can also inspect the project structure, list resources, and read configuration.
|
|
52
|
+
|
|
53
|
+
Available resource types for creation: page, api, layout, middleware, cron, plugin
|
|
54
|
+
- page: Vue page component under src/pages/
|
|
55
|
+
- api: API route handler under src/api/
|
|
56
|
+
- layout: Layout component under src/layouts/
|
|
57
|
+
- middleware: Middleware under src/middleware/
|
|
58
|
+
- cron: Scheduled task under src/server/crons/
|
|
59
|
+
- plugin: Plugin under src/plugins/
|
|
60
|
+
|
|
61
|
+
When creating resources, always use the create_resource tool and confirm the path with the user if ambiguous.
|
|
62
|
+
Be concise and helpful. Use code examples when relevant.
|
|
63
|
+
|
|
64
|
+
When the user asks about the current project, use the list_resources or get_project_info tools to get up-to-date information.
|
|
65
|
+
Do not guess resource paths — always verify with tools first.`;
|
|
66
|
+
let toolCallCounter = 0;
|
|
67
|
+
function nextToolId() {
|
|
68
|
+
return `call_${++toolCallCounter}`;
|
|
69
|
+
}
|
|
70
|
+
function createAiServer(crud, getInfo, onStreamChunk) {
|
|
71
|
+
function getToolDefinitions() {
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
name: "list_resources",
|
|
75
|
+
description: "List all resources of a given type in the project",
|
|
76
|
+
parameters: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: { type: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "Type of resource to list",
|
|
81
|
+
enum: [
|
|
82
|
+
"pages",
|
|
83
|
+
"apis",
|
|
84
|
+
"layouts",
|
|
85
|
+
"middlewares",
|
|
86
|
+
"crons",
|
|
87
|
+
"all"
|
|
88
|
+
]
|
|
89
|
+
} },
|
|
90
|
+
required: ["type"]
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "create_resource",
|
|
95
|
+
description: "Create a new resource (page, api, layout, middleware, cron, plugin)",
|
|
96
|
+
parameters: {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: {
|
|
99
|
+
type: {
|
|
100
|
+
type: "string",
|
|
101
|
+
description: "Type of resource to create",
|
|
102
|
+
enum: [
|
|
103
|
+
"page",
|
|
104
|
+
"api",
|
|
105
|
+
"layout",
|
|
106
|
+
"middleware",
|
|
107
|
+
"cron",
|
|
108
|
+
"plugin"
|
|
109
|
+
]
|
|
110
|
+
},
|
|
111
|
+
path: {
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "Route path (e.g., \"about\", \"users/[id]\", \"auth\")"
|
|
114
|
+
},
|
|
115
|
+
method: {
|
|
116
|
+
type: "string",
|
|
117
|
+
description: "HTTP method for API routes",
|
|
118
|
+
enum: [
|
|
119
|
+
"GET",
|
|
120
|
+
"POST",
|
|
121
|
+
"PUT",
|
|
122
|
+
"PATCH",
|
|
123
|
+
"DELETE"
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
schedule: {
|
|
127
|
+
type: "string",
|
|
128
|
+
description: "Cron schedule expression (e.g., \"0 0 * * *\" for daily at midnight)"
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
required: ["type", "path"]
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "delete_resource",
|
|
136
|
+
description: "Delete a resource (creates backup by default)",
|
|
137
|
+
parameters: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
type: {
|
|
141
|
+
type: "string",
|
|
142
|
+
description: "Type of resource to delete",
|
|
143
|
+
enum: [
|
|
144
|
+
"page",
|
|
145
|
+
"api",
|
|
146
|
+
"layout",
|
|
147
|
+
"middleware",
|
|
148
|
+
"cron",
|
|
149
|
+
"plugin"
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
path: {
|
|
153
|
+
type: "string",
|
|
154
|
+
description: "Resource path"
|
|
155
|
+
},
|
|
156
|
+
force: {
|
|
157
|
+
type: "boolean",
|
|
158
|
+
description: "Permanently delete without backup"
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
required: ["type", "path"]
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "get_project_info",
|
|
166
|
+
description: "Get project overview information (version, uptime, counts, config)",
|
|
167
|
+
parameters: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "read_resource",
|
|
174
|
+
description: "Read the content of a resource file",
|
|
175
|
+
parameters: {
|
|
176
|
+
type: "object",
|
|
177
|
+
properties: {
|
|
178
|
+
type: {
|
|
179
|
+
type: "string",
|
|
180
|
+
description: "Resource type",
|
|
181
|
+
enum: [
|
|
182
|
+
"page",
|
|
183
|
+
"api",
|
|
184
|
+
"layout",
|
|
185
|
+
"middleware",
|
|
186
|
+
"cron",
|
|
187
|
+
"env",
|
|
188
|
+
"config"
|
|
189
|
+
]
|
|
190
|
+
},
|
|
191
|
+
path: {
|
|
192
|
+
type: "string",
|
|
193
|
+
description: "Resource path (not needed for env/config)"
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
required: ["type"]
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "set_env",
|
|
201
|
+
description: "Set or update an environment variable",
|
|
202
|
+
parameters: {
|
|
203
|
+
type: "object",
|
|
204
|
+
properties: {
|
|
205
|
+
key: {
|
|
206
|
+
type: "string",
|
|
207
|
+
description: "Environment variable name"
|
|
208
|
+
},
|
|
209
|
+
value: {
|
|
210
|
+
type: "string",
|
|
211
|
+
description: "Environment variable value"
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
required: ["key", "value"]
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
async function executeToolCall(call) {
|
|
220
|
+
try {
|
|
221
|
+
const args = call.arguments;
|
|
222
|
+
switch (call.name) {
|
|
223
|
+
case "list_resources": {
|
|
224
|
+
const type = args.type;
|
|
225
|
+
const info = getInfo();
|
|
226
|
+
const result = {};
|
|
227
|
+
if (type === "pages" || type === "all") result.pages = info.pagesList?.map((p) => ({
|
|
228
|
+
path: p.path,
|
|
229
|
+
name: p.name,
|
|
230
|
+
file: p.filePath
|
|
231
|
+
})) || [];
|
|
232
|
+
if (type === "apis" || type === "all") result.apis = info.routes?.filter((r) => r.filePath).map((r) => ({
|
|
233
|
+
method: r.method,
|
|
234
|
+
path: r.path,
|
|
235
|
+
file: r.filePath
|
|
236
|
+
})) || [];
|
|
237
|
+
if (type === "layouts" || type === "all") result.layouts = info.layoutsList?.map((l) => ({
|
|
238
|
+
name: l.name,
|
|
239
|
+
path: l.path,
|
|
240
|
+
default: l.isDefault,
|
|
241
|
+
file: l.filePath
|
|
242
|
+
})) || [];
|
|
243
|
+
if (type === "middlewares" || type === "all") result.middlewares = info.middlewaresList?.map((m) => ({
|
|
244
|
+
path: m.path,
|
|
245
|
+
global: m.global,
|
|
246
|
+
file: m.filePath
|
|
247
|
+
})) || [];
|
|
248
|
+
if (type === "crons" || type === "all") result.crons = info.cronsList?.map((c) => ({
|
|
249
|
+
name: c.name,
|
|
250
|
+
schedule: c.schedule,
|
|
251
|
+
file: c.filePath
|
|
252
|
+
})) || [];
|
|
253
|
+
return {
|
|
254
|
+
toolCallId: call.id,
|
|
255
|
+
result
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
case "create_resource": {
|
|
259
|
+
const result = await crud.create({
|
|
260
|
+
type: args.type,
|
|
261
|
+
path: args.path,
|
|
262
|
+
method: args.method,
|
|
263
|
+
schedule: args.schedule,
|
|
264
|
+
force: false
|
|
265
|
+
});
|
|
266
|
+
return {
|
|
267
|
+
toolCallId: call.id,
|
|
268
|
+
result
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
case "delete_resource": {
|
|
272
|
+
const result = await crud.delete({
|
|
273
|
+
type: args.type,
|
|
274
|
+
path: args.path,
|
|
275
|
+
force: args.force || false
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
toolCallId: call.id,
|
|
279
|
+
result
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
case "get_project_info": {
|
|
283
|
+
const info = getInfo();
|
|
284
|
+
return {
|
|
285
|
+
toolCallId: call.id,
|
|
286
|
+
result: {
|
|
287
|
+
version: info.version,
|
|
288
|
+
uptime: Date.now() - info.startTime,
|
|
289
|
+
pages: info.pages,
|
|
290
|
+
apiRoutes: info.apiRoutes,
|
|
291
|
+
middlewares: info.middleware,
|
|
292
|
+
layouts: info.layouts,
|
|
293
|
+
crons: info.crons,
|
|
294
|
+
presets: info.presets
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
case "read_resource": {
|
|
299
|
+
const result = await crud.read({
|
|
300
|
+
type: args.type,
|
|
301
|
+
path: args.path
|
|
302
|
+
});
|
|
303
|
+
return {
|
|
304
|
+
toolCallId: call.id,
|
|
305
|
+
result
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
case "set_env": {
|
|
309
|
+
const result = await crud.update({
|
|
310
|
+
type: "env",
|
|
311
|
+
key: args.key,
|
|
312
|
+
value: args.value
|
|
313
|
+
});
|
|
314
|
+
return {
|
|
315
|
+
toolCallId: call.id,
|
|
316
|
+
result
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
default: return {
|
|
320
|
+
toolCallId: call.id,
|
|
321
|
+
error: `Unknown tool: ${call.name}`
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
return {
|
|
326
|
+
toolCallId: call.id,
|
|
327
|
+
error: err instanceof Error ? err.message : String(err)
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function parseCommand(input) {
|
|
332
|
+
const lower = input.trim().toLowerCase();
|
|
333
|
+
const createMatch = input.match(/^(?:create|add|new|make|generate|scaffold|g)\s+(?:a\s+)?(page|api|layout|middleware|cron|plugin)\s+(?:at\s+)?["']?([\w\-/[\].]+)["']?(?:\s+(?:with|using)\s+(?:method\s+)?(GET|POST|PUT|PATCH|DELETE))?(?:\s+(?:schedule|cron)?\s+["']?([*\d/\-,\s]+)["']?)?/i);
|
|
334
|
+
if (createMatch) {
|
|
335
|
+
const [, type, path, method, schedule] = createMatch;
|
|
336
|
+
const params = {
|
|
337
|
+
type: type.toLowerCase(),
|
|
338
|
+
path
|
|
339
|
+
};
|
|
340
|
+
if (method) params.method = method.toUpperCase();
|
|
341
|
+
if (schedule) params.schedule = schedule.trim();
|
|
342
|
+
return { toolCalls: [{
|
|
343
|
+
id: nextToolId(),
|
|
344
|
+
name: "create_resource",
|
|
345
|
+
arguments: params
|
|
346
|
+
}] };
|
|
347
|
+
}
|
|
348
|
+
const deleteMatch = input.match(/^(?:delete|remove|rm|del)\s+(?:the\s+)?(page|api|layout|middleware|cron|plugin)\s+(?:at\s+)?["']?([\w\-/[\].]+)["']?/i);
|
|
349
|
+
if (deleteMatch) {
|
|
350
|
+
const [, type, path] = deleteMatch;
|
|
351
|
+
return { toolCalls: [{
|
|
352
|
+
id: nextToolId(),
|
|
353
|
+
name: "delete_resource",
|
|
354
|
+
arguments: {
|
|
355
|
+
type: type.toLowerCase(),
|
|
356
|
+
path,
|
|
357
|
+
force: lower.includes("force") || lower.includes("permanent")
|
|
358
|
+
}
|
|
359
|
+
}] };
|
|
360
|
+
}
|
|
361
|
+
const listMatch = input.match(/^(?:list|show|ls|get)\s+(all|pages|apis?|layouts?|middlewares?|crons?)(?:\s*$)/i);
|
|
362
|
+
if (listMatch) {
|
|
363
|
+
let type = listMatch[1].toLowerCase();
|
|
364
|
+
if (type === "all") type = "all";
|
|
365
|
+
else if (type.startsWith("api")) type = "apis";
|
|
366
|
+
else if (type.startsWith("layout")) type = "layouts";
|
|
367
|
+
else if (type.startsWith("middleware")) type = "middlewares";
|
|
368
|
+
else if (type.startsWith("cron")) type = "crons";
|
|
369
|
+
else type = "pages";
|
|
370
|
+
return { toolCalls: [{
|
|
371
|
+
id: nextToolId(),
|
|
372
|
+
name: "list_resources",
|
|
373
|
+
arguments: { type }
|
|
374
|
+
}] };
|
|
375
|
+
}
|
|
376
|
+
if (/^(?:project\s+)?(?:info|status|overview|stats|about)$/i.test(input.trim())) return { toolCalls: [{
|
|
377
|
+
id: nextToolId(),
|
|
378
|
+
name: "get_project_info",
|
|
379
|
+
arguments: {}
|
|
380
|
+
}] };
|
|
381
|
+
if (/^(help|\?|commands|what can you do)/i.test(input.trim())) return { response: `I can help you with the following:
|
|
382
|
+
|
|
383
|
+
**Create resources:**
|
|
384
|
+
- \`create page about\` — Create a new page at /about
|
|
385
|
+
- \`create api users with method POST\` — Create a POST API route
|
|
386
|
+
- \`create cron daily-cleanup schedule "0 0 * * *"\` — Create a daily cron job
|
|
387
|
+
- \`create layout admin\` — Create an admin layout
|
|
388
|
+
|
|
389
|
+
**Delete resources:**
|
|
390
|
+
- \`delete page about\` — Delete a page (with backup)
|
|
391
|
+
- \`delete api users --force\` — Permanently delete an API
|
|
392
|
+
|
|
393
|
+
**Inspect project:**
|
|
394
|
+
- \`list pages\` / \`list apis\` / \`list all\` — List resources
|
|
395
|
+
- \`project info\` — Show project overview
|
|
396
|
+
|
|
397
|
+
**Environment:**
|
|
398
|
+
- \`set env DATABASE_URL=postgres://...\` — Set environment variable
|
|
399
|
+
|
|
400
|
+
You can also ask me questions in natural language, or configure a DeepSeek/OpenAI-compatible API key for more advanced AI assistance.` };
|
|
401
|
+
const setEnvMatch = input.match(/^(?:set\s+env|env\s+set|add\s+env)\s+(\w+)\s*=\s*(.+)$/i);
|
|
402
|
+
if (setEnvMatch) return { toolCalls: [{
|
|
403
|
+
id: nextToolId(),
|
|
404
|
+
name: "set_env",
|
|
405
|
+
arguments: {
|
|
406
|
+
key: setEnvMatch[1],
|
|
407
|
+
value: setEnvMatch[2].trim()
|
|
408
|
+
}
|
|
409
|
+
}] };
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
function formatToolResult(name, result) {
|
|
413
|
+
if (name === "list_resources") {
|
|
414
|
+
const r = result;
|
|
415
|
+
const lines = [];
|
|
416
|
+
for (const [key, items] of Object.entries(r)) {
|
|
417
|
+
lines.push(`**${key}** (${items.length}):`);
|
|
418
|
+
for (const item of items.slice(0, 20)) {
|
|
419
|
+
const obj = item;
|
|
420
|
+
const path = obj.path || obj.name || "";
|
|
421
|
+
const extra = obj.method ? ` [${obj.method}]` : obj.default ? " (default)" : "";
|
|
422
|
+
lines.push(` - ${path}${extra}`);
|
|
423
|
+
}
|
|
424
|
+
if (items.length > 20) lines.push(` ... and ${items.length - 20} more`);
|
|
425
|
+
lines.push("");
|
|
426
|
+
}
|
|
427
|
+
return lines.join("\n");
|
|
428
|
+
}
|
|
429
|
+
if (name === "create_resource") {
|
|
430
|
+
const r = result;
|
|
431
|
+
if (r.success && r.created?.length) return `✅ Created successfully:\n${r.created.map((f) => ` - ${f}`).join("\n")}`;
|
|
432
|
+
if (r.errors?.length) return `❌ Failed: ${r.errors.join(", ")}`;
|
|
433
|
+
if (r.skipped?.length) return `⚠️ Skipped (already exists, use --force to overwrite):\n${r.skipped.map((f) => ` - ${f}`).join("\n")}`;
|
|
434
|
+
return JSON.stringify(result, null, 2);
|
|
435
|
+
}
|
|
436
|
+
if (name === "delete_resource") {
|
|
437
|
+
const r = result;
|
|
438
|
+
if (r.success && r.deleted?.length) return `🗑️ Deleted successfully:\n${r.deleted.map((f) => ` - ${f}`).join("\n")}`;
|
|
439
|
+
if (r.errors?.length) return `❌ Failed: ${r.errors.join(", ")}`;
|
|
440
|
+
return JSON.stringify(result, null, 2);
|
|
441
|
+
}
|
|
442
|
+
if (name === "get_project_info") {
|
|
443
|
+
const r = result;
|
|
444
|
+
const uptimeMs = r.uptime;
|
|
445
|
+
const s = Math.floor(uptimeMs / 1e3);
|
|
446
|
+
const m = Math.floor(s / 60);
|
|
447
|
+
const h = Math.floor(m / 60);
|
|
448
|
+
const uptime = h > 0 ? `${h}h ${m % 60}m` : m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
|
|
449
|
+
return `**Project Info:**
|
|
450
|
+
- Version: ${r.version}
|
|
451
|
+
- Uptime: ${uptime}
|
|
452
|
+
- Pages: ${r.pages}
|
|
453
|
+
- API Routes: ${r.apiRoutes}
|
|
454
|
+
- Middlewares: ${r.middlewares}
|
|
455
|
+
- Layouts: ${r.layouts}
|
|
456
|
+
- Cron Jobs: ${r.crons}
|
|
457
|
+
- Presets: ${r.presets.join(", ") || "none"}`;
|
|
458
|
+
}
|
|
459
|
+
if (name === "set_env") {
|
|
460
|
+
const r = result;
|
|
461
|
+
if (r.success) return "✅ Environment variable set successfully.";
|
|
462
|
+
return `❌ Failed: ${r.errors?.join(", ") || "Unknown error"}`;
|
|
463
|
+
}
|
|
464
|
+
if (name === "read_resource") {
|
|
465
|
+
const r = result;
|
|
466
|
+
if (r.error) return `❌ ${r.error}`;
|
|
467
|
+
if (r.content) return `\`\`\`\n${r.content}\n\`\`\``;
|
|
468
|
+
if (r.data) return `\`\`\`json\n${JSON.stringify(r.data, null, 2)}\n\`\`\``;
|
|
469
|
+
return "(empty)";
|
|
470
|
+
}
|
|
471
|
+
return JSON.stringify(result, null, 2);
|
|
472
|
+
}
|
|
473
|
+
function convertToCoreMessages(messages) {
|
|
474
|
+
return messages.filter((m) => m.role !== "tool" || m.toolCallId).map((m) => {
|
|
475
|
+
if (m.role === "assistant" && m.toolCalls?.length) return {
|
|
476
|
+
role: "assistant",
|
|
477
|
+
content: [...m.content ? [{
|
|
478
|
+
type: "text",
|
|
479
|
+
text: m.content
|
|
480
|
+
}] : [], ...m.toolCalls.map((tc) => ({
|
|
481
|
+
type: "tool-call",
|
|
482
|
+
toolCallId: tc.id,
|
|
483
|
+
toolName: tc.name,
|
|
484
|
+
input: tc.arguments
|
|
485
|
+
}))]
|
|
486
|
+
};
|
|
487
|
+
if (m.role === "tool") return {
|
|
488
|
+
role: "tool",
|
|
489
|
+
content: [{
|
|
490
|
+
type: "tool-result",
|
|
491
|
+
toolCallId: m.toolCallId,
|
|
492
|
+
toolName: "tool",
|
|
493
|
+
output: {
|
|
494
|
+
type: "json",
|
|
495
|
+
value: typeof m.content === "string" ? m.content : JSON.parse(m.content)
|
|
496
|
+
}
|
|
497
|
+
}]
|
|
498
|
+
};
|
|
499
|
+
return {
|
|
500
|
+
role: m.role,
|
|
501
|
+
content: m.content
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
async function buildAiSdkTools() {
|
|
506
|
+
const ai = await import("ai");
|
|
507
|
+
const defs = getToolDefinitions();
|
|
508
|
+
const tools = {};
|
|
509
|
+
for (const def of defs) tools[def.name] = ai.tool({
|
|
510
|
+
description: def.description,
|
|
511
|
+
inputSchema: ai.jsonSchema(def.parameters),
|
|
512
|
+
execute: async (args) => {
|
|
513
|
+
const result = await executeToolCall({
|
|
514
|
+
id: nextToolId(),
|
|
515
|
+
name: def.name,
|
|
516
|
+
arguments: args
|
|
517
|
+
});
|
|
518
|
+
return result.error ? { error: result.error } : result.result;
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
return tools;
|
|
522
|
+
}
|
|
523
|
+
async function chat(options) {
|
|
524
|
+
const { messages, apiKey, apiBase, model, requestId } = options;
|
|
525
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
|
526
|
+
if (!lastUserMsg) return { message: {
|
|
527
|
+
role: "assistant",
|
|
528
|
+
content: "Hello! I am ubean Assistant. Type \"help\" to see what I can do.",
|
|
529
|
+
timestamp: Date.now()
|
|
530
|
+
} };
|
|
531
|
+
const parsed = parseCommand(lastUserMsg.content);
|
|
532
|
+
if (parsed?.response) return { message: {
|
|
533
|
+
role: "assistant",
|
|
534
|
+
content: parsed.response,
|
|
535
|
+
timestamp: Date.now()
|
|
536
|
+
} };
|
|
537
|
+
if (parsed?.toolCalls && parsed.toolCalls.length > 0) {
|
|
538
|
+
const toolResults = [];
|
|
539
|
+
for (const call of parsed.toolCalls) {
|
|
540
|
+
const result = await executeToolCall(call);
|
|
541
|
+
toolResults.push(result);
|
|
542
|
+
}
|
|
543
|
+
const responseParts = [];
|
|
544
|
+
for (let i = 0; i < parsed.toolCalls.length; i++) {
|
|
545
|
+
const call = parsed.toolCalls[i];
|
|
546
|
+
const tr = toolResults[i];
|
|
547
|
+
if (tr.error) responseParts.push(`❌ Error executing ${call.name}: ${tr.error}`);
|
|
548
|
+
else responseParts.push(formatToolResult(call.name, tr.result));
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
message: {
|
|
552
|
+
role: "assistant",
|
|
553
|
+
content: responseParts.join("\n\n"),
|
|
554
|
+
toolCalls: parsed.toolCalls,
|
|
555
|
+
timestamp: Date.now()
|
|
556
|
+
},
|
|
557
|
+
toolResults
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const resolvedApiKey = apiKey || process.env.DEEPSEEK_API_KEY || process.env.UBEAN_AI_API_KEY || process.env.OPENAI_API_KEY;
|
|
561
|
+
const resolvedApiBase = apiBase || process.env.UBEAN_AI_API_BASE || "https://api.deepseek.com/v1";
|
|
562
|
+
const resolvedModel = model || process.env.UBEAN_AI_MODEL || "deepseek-chat";
|
|
563
|
+
if (resolvedApiKey) try {
|
|
564
|
+
return await callLlmApi({
|
|
565
|
+
messages,
|
|
566
|
+
apiKey: resolvedApiKey,
|
|
567
|
+
apiBase: resolvedApiBase,
|
|
568
|
+
model: resolvedModel,
|
|
569
|
+
requestId
|
|
570
|
+
});
|
|
571
|
+
} catch (err) {
|
|
572
|
+
return { message: {
|
|
573
|
+
role: "assistant",
|
|
574
|
+
content: `I didn't understand that command. Type "help" to see available commands.\n\n(LLM API error: ${err instanceof Error ? err.message : String(err)})`,
|
|
575
|
+
timestamp: Date.now()
|
|
576
|
+
} };
|
|
577
|
+
}
|
|
578
|
+
return { message: {
|
|
579
|
+
role: "assistant",
|
|
580
|
+
content: `I didn't understand that command. Type "help" to see what I can do.\n\nTip: For natural language assistance, configure a DeepSeek or OpenAI-compatible API endpoint:\n\n\`\`\`ts\n// ubean.config.ts\nexport default defineConfig({\n devtools: {\n ai: {\n apiKey: process.env.DEEPSEEK_API_KEY,\n apiBase: 'https://api.deepseek.com/v1',\n model: 'deepseek-chat'\n }\n }\n});\n\`\`\`\n\nOr set the \`DEEPSEEK_API_KEY\` environment variable.`,
|
|
581
|
+
timestamp: Date.now()
|
|
582
|
+
} };
|
|
583
|
+
}
|
|
584
|
+
async function callLlmApi(options) {
|
|
585
|
+
const { messages, apiKey, apiBase, model, requestId } = options;
|
|
586
|
+
const [{ streamText, stepCountIs }, { createOpenAICompatible }] = await Promise.all([import("ai"), import("@ai-sdk/openai-compatible")]);
|
|
587
|
+
const aiModel = createOpenAICompatible({
|
|
588
|
+
baseURL: apiBase,
|
|
589
|
+
name: apiBase.includes("deepseek") ? DEEPSEEK_PROVIDER_NAME : "ubean-ai",
|
|
590
|
+
apiKey
|
|
591
|
+
}).chatModel(model);
|
|
592
|
+
const coreMessages = convertToCoreMessages(messages);
|
|
593
|
+
const tools = await buildAiSdkTools();
|
|
594
|
+
const result = streamText({
|
|
595
|
+
model: aiModel,
|
|
596
|
+
system: SYSTEM_PROMPT,
|
|
597
|
+
messages: coreMessages,
|
|
598
|
+
tools,
|
|
599
|
+
stopWhen: stepCountIs(5)
|
|
600
|
+
});
|
|
601
|
+
let accumulated = "";
|
|
602
|
+
const streamToolCalls = [];
|
|
603
|
+
const streamToolResults = [];
|
|
604
|
+
let streamError;
|
|
605
|
+
if (requestId && onStreamChunk) {
|
|
606
|
+
try {
|
|
607
|
+
for await (const part of result.fullStream) switch (part.type) {
|
|
608
|
+
case "text-delta":
|
|
609
|
+
accumulated += part.text;
|
|
610
|
+
onStreamChunk({
|
|
611
|
+
requestId,
|
|
612
|
+
text: accumulated,
|
|
613
|
+
done: false
|
|
614
|
+
});
|
|
615
|
+
break;
|
|
616
|
+
case "tool-call":
|
|
617
|
+
streamToolCalls.push({
|
|
618
|
+
id: part.toolCallId,
|
|
619
|
+
name: part.toolName,
|
|
620
|
+
arguments: part.input
|
|
621
|
+
});
|
|
622
|
+
onStreamChunk({
|
|
623
|
+
requestId,
|
|
624
|
+
text: accumulated,
|
|
625
|
+
done: false,
|
|
626
|
+
toolCalls: [...streamToolCalls]
|
|
627
|
+
});
|
|
628
|
+
break;
|
|
629
|
+
case "tool-result":
|
|
630
|
+
streamToolResults.push({
|
|
631
|
+
toolCallId: part.toolCallId,
|
|
632
|
+
result: part.output
|
|
633
|
+
});
|
|
634
|
+
onStreamChunk({
|
|
635
|
+
requestId,
|
|
636
|
+
text: accumulated,
|
|
637
|
+
done: false,
|
|
638
|
+
toolCalls: [...streamToolCalls],
|
|
639
|
+
toolResults: [...streamToolResults]
|
|
640
|
+
});
|
|
641
|
+
break;
|
|
642
|
+
case "error":
|
|
643
|
+
streamError = part.error instanceof Error ? part.error.message : String(part.error);
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
} catch (err) {
|
|
647
|
+
streamError = err instanceof Error ? err.message : String(err);
|
|
648
|
+
}
|
|
649
|
+
if (streamError) {
|
|
650
|
+
onStreamChunk({
|
|
651
|
+
requestId,
|
|
652
|
+
text: accumulated,
|
|
653
|
+
done: true,
|
|
654
|
+
error: streamError
|
|
655
|
+
});
|
|
656
|
+
return {
|
|
657
|
+
message: {
|
|
658
|
+
role: "assistant",
|
|
659
|
+
content: accumulated || `Error: ${streamError}`,
|
|
660
|
+
toolCalls: streamToolCalls.length ? streamToolCalls : void 0,
|
|
661
|
+
timestamp: Date.now()
|
|
662
|
+
},
|
|
663
|
+
toolResults: streamToolResults.length ? streamToolResults : void 0
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
} else try {
|
|
667
|
+
for await (const _ of result.fullStream) {
|
|
668
|
+
if (_.type === "text-delta") accumulated += _.text;
|
|
669
|
+
if (_.type === "tool-call") streamToolCalls.push({
|
|
670
|
+
id: _.toolCallId,
|
|
671
|
+
name: _.toolName,
|
|
672
|
+
arguments: _.input
|
|
673
|
+
});
|
|
674
|
+
if (_.type === "tool-result") streamToolResults.push({
|
|
675
|
+
toolCallId: _.toolCallId,
|
|
676
|
+
result: _.output
|
|
677
|
+
});
|
|
678
|
+
if (_.type === "error") streamError = _.error instanceof Error ? _.error.message : String(_.error);
|
|
679
|
+
}
|
|
680
|
+
} catch (err) {
|
|
681
|
+
streamError = err instanceof Error ? err.message : String(err);
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
message: {
|
|
685
|
+
role: "assistant",
|
|
686
|
+
content: accumulated || (streamError ? `Error: ${streamError}` : ""),
|
|
687
|
+
toolCalls: streamToolCalls.length ? streamToolCalls : void 0,
|
|
688
|
+
timestamp: Date.now()
|
|
689
|
+
},
|
|
690
|
+
toolResults: streamToolResults.length ? streamToolResults : void 0
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
getToolDefinitions,
|
|
695
|
+
executeToolCall,
|
|
696
|
+
chat,
|
|
697
|
+
parseCommand,
|
|
698
|
+
formatToolResult
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
//#endregion
|
|
702
|
+
//#region src/server/crud.ts
|
|
703
|
+
const SCAFFOLD_TYPES = [
|
|
704
|
+
"page",
|
|
705
|
+
"api",
|
|
706
|
+
"layout",
|
|
707
|
+
"middleware",
|
|
708
|
+
"reuse"
|
|
709
|
+
];
|
|
710
|
+
const SCAFFOLD_TYPE_SET = new Set(SCAFFOLD_TYPES);
|
|
711
|
+
function createCrudServer(options) {
|
|
712
|
+
const { cwd, hooks, getEnv, setEnv, getConfig, onFileChange, scaffoldOps } = options;
|
|
713
|
+
if (!scaffoldOps) throw new Error("[ubean:devtools] scaffoldOps is required for CRUD server. Make sure to pass scaffold functions via ubeanDevtoolsPlugin options.");
|
|
714
|
+
const fs = scaffoldOps.createFsOps(cwd);
|
|
715
|
+
/**
|
|
716
|
+
* Determine the correct base directory for a scaffold type, taking the
|
|
717
|
+
* project's `dir` config into account. Returns an absolute path.
|
|
718
|
+
*
|
|
719
|
+
* For the 'api' type:
|
|
720
|
+
* 1. Use `config.dir.routes` (default 'routes') relative to srcDir.
|
|
721
|
+
* 2. If that directory doesn't exist, fall back to src/routes.
|
|
722
|
+
*
|
|
723
|
+
* For other types, the scaffold function's built-in defaults are used.
|
|
724
|
+
*/
|
|
725
|
+
function getBaseDirForType(type) {
|
|
726
|
+
if (type !== "api") return void 0;
|
|
727
|
+
const config = getConfig?.() ?? {};
|
|
728
|
+
const dir = config.dir ?? {};
|
|
729
|
+
const rawSrcDir = config.srcDir || "src";
|
|
730
|
+
const srcDir = isAbsolute(rawSrcDir) ? rawSrcDir : resolve(cwd, rawSrcDir);
|
|
731
|
+
const primaryDir = join(srcDir, dir.routes || "routes");
|
|
732
|
+
if (existsSync(primaryDir)) return primaryDir;
|
|
733
|
+
const fallbackDir = join(srcDir, "routes");
|
|
734
|
+
if (existsSync(fallbackDir)) return fallbackDir;
|
|
735
|
+
return primaryDir;
|
|
736
|
+
}
|
|
737
|
+
function normalizeResult(scaffoldRes) {
|
|
738
|
+
return {
|
|
739
|
+
success: !(scaffoldRes.errors && scaffoldRes.errors.length > 0),
|
|
740
|
+
created: scaffoldRes.created,
|
|
741
|
+
deleted: scaffoldRes.deleted,
|
|
742
|
+
restored: scaffoldRes.restored,
|
|
743
|
+
skipped: scaffoldRes.skipped,
|
|
744
|
+
errors: scaffoldRes.errors
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
async function notifyChange() {
|
|
748
|
+
if (onFileChange) await onFileChange();
|
|
749
|
+
}
|
|
750
|
+
async function create(params) {
|
|
751
|
+
const { type, path, method, schedule, content, force } = params;
|
|
752
|
+
if (hooks) await hooks.runHook("beforeCreate", {
|
|
753
|
+
type,
|
|
754
|
+
path,
|
|
755
|
+
content
|
|
756
|
+
});
|
|
757
|
+
try {
|
|
758
|
+
let result;
|
|
759
|
+
if (SCAFFOLD_TYPE_SET.has(type)) {
|
|
760
|
+
const baseDir = getBaseDirForType(type);
|
|
761
|
+
const scaffoldRes = await scaffoldOps?.scaffold({
|
|
762
|
+
cwd,
|
|
763
|
+
type,
|
|
764
|
+
path,
|
|
765
|
+
method,
|
|
766
|
+
force,
|
|
767
|
+
dry: false,
|
|
768
|
+
...baseDir ? { baseDir } : {}
|
|
769
|
+
});
|
|
770
|
+
if (content && scaffoldRes?.created?.length) await fs.writeFile(scaffoldRes.created[0], content);
|
|
771
|
+
result = normalizeResult(scaffoldRes || {});
|
|
772
|
+
} else if (type === "cron") {
|
|
773
|
+
const cronDir = "src/server/crons";
|
|
774
|
+
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
|
|
775
|
+
const filePath = join(cronDir, normalizedPath.endsWith(".ts") ? normalizedPath : `${normalizedPath}.ts`);
|
|
776
|
+
if (await fs.exists(filePath) && !force) result = {
|
|
777
|
+
success: false,
|
|
778
|
+
skipped: [filePath],
|
|
779
|
+
errors: ["File already exists"]
|
|
780
|
+
};
|
|
781
|
+
else {
|
|
782
|
+
const cronContent = content || `import { defineScheduled } from 'ubean';
|
|
783
|
+
|
|
784
|
+
export default defineScheduled({
|
|
785
|
+
schedule: '${schedule || "* * * * *"}',
|
|
786
|
+
async run() {
|
|
787
|
+
console.log('Cron job running');
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
`;
|
|
791
|
+
await fs.writeFile(filePath, cronContent);
|
|
792
|
+
result = {
|
|
793
|
+
success: true,
|
|
794
|
+
created: [filePath]
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
} else if (type === "plugin") {
|
|
798
|
+
const pluginDir = "src/plugins";
|
|
799
|
+
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
|
|
800
|
+
const fileName = normalizedPath.endsWith(".ts") ? normalizedPath : `${normalizedPath}.ts`;
|
|
801
|
+
const filePath = join(pluginDir, fileName);
|
|
802
|
+
if (await fs.exists(filePath) && !force) result = {
|
|
803
|
+
success: false,
|
|
804
|
+
skipped: [filePath],
|
|
805
|
+
errors: ["File already exists"]
|
|
806
|
+
};
|
|
807
|
+
else {
|
|
808
|
+
const pluginContent = content || `import { definePlugin } from 'ubean';
|
|
809
|
+
|
|
810
|
+
export default definePlugin({
|
|
811
|
+
name: '${fileName.replace(/\.ts$/, "").replace(/[^\w]/g, "-")}',
|
|
812
|
+
setup() {
|
|
813
|
+
console.log('Plugin loaded');
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
`;
|
|
817
|
+
await fs.writeFile(filePath, pluginContent);
|
|
818
|
+
result = {
|
|
819
|
+
success: true,
|
|
820
|
+
created: [filePath]
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
} else result = {
|
|
824
|
+
success: false,
|
|
825
|
+
errors: [`Unsupported resource type: ${type}`]
|
|
826
|
+
};
|
|
827
|
+
if (hooks) await hooks.runHook("afterCreate", {
|
|
828
|
+
type,
|
|
829
|
+
path,
|
|
830
|
+
content
|
|
831
|
+
});
|
|
832
|
+
if (result.success) await notifyChange();
|
|
833
|
+
return result;
|
|
834
|
+
} catch (err) {
|
|
835
|
+
return {
|
|
836
|
+
success: false,
|
|
837
|
+
errors: [err instanceof Error ? err.message : String(err)]
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
async function read(params) {
|
|
842
|
+
const { type, path } = params;
|
|
843
|
+
try {
|
|
844
|
+
if (type === "env") {
|
|
845
|
+
if (getEnv) return {
|
|
846
|
+
success: true,
|
|
847
|
+
data: getEnv()
|
|
848
|
+
};
|
|
849
|
+
return {
|
|
850
|
+
success: false,
|
|
851
|
+
error: "Env not available"
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
if (type === "config") {
|
|
855
|
+
if (path) {
|
|
856
|
+
if (!await fs.exists(path)) return {
|
|
857
|
+
success: false,
|
|
858
|
+
error: `File not found: ${path}`
|
|
859
|
+
};
|
|
860
|
+
return {
|
|
861
|
+
success: true,
|
|
862
|
+
content: await fs.readFile(path)
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
if (getConfig) return {
|
|
866
|
+
success: true,
|
|
867
|
+
data: getConfig()
|
|
868
|
+
};
|
|
869
|
+
return {
|
|
870
|
+
success: false,
|
|
871
|
+
error: "Config not available"
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
if (!path) return {
|
|
875
|
+
success: false,
|
|
876
|
+
error: "Path is required for file read"
|
|
877
|
+
};
|
|
878
|
+
if (SCAFFOLD_TYPE_SET.has(type) || type === "cron" || type === "plugin") {
|
|
879
|
+
if (!await fs.exists(path)) return {
|
|
880
|
+
success: false,
|
|
881
|
+
error: `File not found: ${path}`
|
|
882
|
+
};
|
|
883
|
+
return {
|
|
884
|
+
success: true,
|
|
885
|
+
content: await fs.readFile(path)
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
success: false,
|
|
890
|
+
error: `Unsupported resource type: ${type}`
|
|
891
|
+
};
|
|
892
|
+
} catch (err) {
|
|
893
|
+
return {
|
|
894
|
+
success: false,
|
|
895
|
+
error: err instanceof Error ? err.message : String(err)
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
async function update(params) {
|
|
900
|
+
const { type, path, key, content, value } = params;
|
|
901
|
+
if (hooks) await hooks.runHook("beforeUpdate", {
|
|
902
|
+
type,
|
|
903
|
+
path,
|
|
904
|
+
key,
|
|
905
|
+
content,
|
|
906
|
+
value
|
|
907
|
+
});
|
|
908
|
+
try {
|
|
909
|
+
let result;
|
|
910
|
+
if (type === "env") if (!key) result = {
|
|
911
|
+
success: false,
|
|
912
|
+
errors: ["Key is required for env update"]
|
|
913
|
+
};
|
|
914
|
+
else if (!setEnv || !getEnv) result = {
|
|
915
|
+
success: false,
|
|
916
|
+
errors: ["Env not available"]
|
|
917
|
+
};
|
|
918
|
+
else {
|
|
919
|
+
const env = { ...getEnv() };
|
|
920
|
+
if (value !== void 0) env[key] = value;
|
|
921
|
+
else delete env[key];
|
|
922
|
+
setEnv(env);
|
|
923
|
+
result = {
|
|
924
|
+
success: true,
|
|
925
|
+
updated: [`env:${key}`]
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
else if (type === "config") if (!path) result = {
|
|
929
|
+
success: false,
|
|
930
|
+
errors: ["Path is required for config file update"]
|
|
931
|
+
};
|
|
932
|
+
else if (!await fs.exists(path)) result = {
|
|
933
|
+
success: false,
|
|
934
|
+
errors: [`File not found: ${path}`]
|
|
935
|
+
};
|
|
936
|
+
else {
|
|
937
|
+
await fs.writeFile(path, content || "");
|
|
938
|
+
result = {
|
|
939
|
+
success: true,
|
|
940
|
+
updated: [path]
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
else if (!path) result = {
|
|
944
|
+
success: false,
|
|
945
|
+
errors: ["Path is required for file update"]
|
|
946
|
+
};
|
|
947
|
+
else if (SCAFFOLD_TYPE_SET.has(type) || type === "cron" || type === "plugin") if (!await fs.exists(path)) result = {
|
|
948
|
+
success: false,
|
|
949
|
+
errors: [`File not found: ${path}`]
|
|
950
|
+
};
|
|
951
|
+
else {
|
|
952
|
+
await fs.writeFile(path, content || "");
|
|
953
|
+
result = {
|
|
954
|
+
success: true,
|
|
955
|
+
updated: [path]
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
else result = {
|
|
959
|
+
success: false,
|
|
960
|
+
errors: [`Unsupported resource type: ${type}`]
|
|
961
|
+
};
|
|
962
|
+
if (hooks) await hooks.runHook("afterUpdate", {
|
|
963
|
+
type,
|
|
964
|
+
path,
|
|
965
|
+
key,
|
|
966
|
+
content,
|
|
967
|
+
value
|
|
968
|
+
});
|
|
969
|
+
if (result.success) await notifyChange();
|
|
970
|
+
return result;
|
|
971
|
+
} catch (err) {
|
|
972
|
+
return {
|
|
973
|
+
success: false,
|
|
974
|
+
errors: [err instanceof Error ? err.message : String(err)]
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
async function del(params) {
|
|
979
|
+
const { type, path, key, force } = params;
|
|
980
|
+
if (hooks) await hooks.runHook("beforeDelete", {
|
|
981
|
+
type,
|
|
982
|
+
path,
|
|
983
|
+
key
|
|
984
|
+
});
|
|
985
|
+
try {
|
|
986
|
+
let result;
|
|
987
|
+
if (type === "env") if (!key) result = {
|
|
988
|
+
success: false,
|
|
989
|
+
errors: ["Key is required for env delete"]
|
|
990
|
+
};
|
|
991
|
+
else if (!setEnv || !getEnv) result = {
|
|
992
|
+
success: false,
|
|
993
|
+
errors: ["Env not available"]
|
|
994
|
+
};
|
|
995
|
+
else {
|
|
996
|
+
const env = { ...getEnv() };
|
|
997
|
+
delete env[key];
|
|
998
|
+
setEnv(env);
|
|
999
|
+
result = {
|
|
1000
|
+
success: true,
|
|
1001
|
+
deleted: [`env:${key}`]
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
else if (!path) result = {
|
|
1005
|
+
success: false,
|
|
1006
|
+
errors: ["Path is required for file delete"]
|
|
1007
|
+
};
|
|
1008
|
+
else if (SCAFFOLD_TYPE_SET.has(type)) {
|
|
1009
|
+
const baseDir = getBaseDirForType(type);
|
|
1010
|
+
result = normalizeResult(await scaffoldOps?.deleteScaffold({
|
|
1011
|
+
cwd,
|
|
1012
|
+
type,
|
|
1013
|
+
path,
|
|
1014
|
+
force,
|
|
1015
|
+
dry: false,
|
|
1016
|
+
...baseDir ? { baseDir } : {}
|
|
1017
|
+
}) || {});
|
|
1018
|
+
} else if (type === "cron" || type === "plugin") if (!await fs.exists(path)) result = {
|
|
1019
|
+
success: false,
|
|
1020
|
+
errors: [`File not found: ${path}`]
|
|
1021
|
+
};
|
|
1022
|
+
else {
|
|
1023
|
+
if (force) await fs.remove(path);
|
|
1024
|
+
else await fs.createBackup(path, { removeOriginal: true });
|
|
1025
|
+
result = {
|
|
1026
|
+
success: true,
|
|
1027
|
+
deleted: [path]
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
else result = {
|
|
1031
|
+
success: false,
|
|
1032
|
+
errors: [`Unsupported resource type: ${type}`]
|
|
1033
|
+
};
|
|
1034
|
+
if (hooks) await hooks.runHook("afterDelete", {
|
|
1035
|
+
type,
|
|
1036
|
+
path,
|
|
1037
|
+
key
|
|
1038
|
+
});
|
|
1039
|
+
if (result.success) await notifyChange();
|
|
1040
|
+
return result;
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
return {
|
|
1043
|
+
success: false,
|
|
1044
|
+
errors: [err instanceof Error ? err.message : String(err)]
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
async function restore(path) {
|
|
1049
|
+
try {
|
|
1050
|
+
for (const type of SCAFFOLD_TYPES) {
|
|
1051
|
+
const baseDir = getBaseDirForType(type);
|
|
1052
|
+
const scaffoldRes = await scaffoldOps?.recoverScaffold({
|
|
1053
|
+
cwd,
|
|
1054
|
+
type,
|
|
1055
|
+
path,
|
|
1056
|
+
dry: false,
|
|
1057
|
+
...baseDir ? { baseDir } : {}
|
|
1058
|
+
});
|
|
1059
|
+
if (scaffoldRes?.restored?.length) {
|
|
1060
|
+
await notifyChange();
|
|
1061
|
+
return normalizeResult(scaffoldRes);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
const backupPath = `${path}.bak`;
|
|
1065
|
+
if (await fs.exists(backupPath)) {
|
|
1066
|
+
await fs.copyFile(backupPath, path);
|
|
1067
|
+
await fs.removeBackup(path);
|
|
1068
|
+
await notifyChange();
|
|
1069
|
+
return {
|
|
1070
|
+
success: true,
|
|
1071
|
+
restored: [path]
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
return {
|
|
1075
|
+
success: false,
|
|
1076
|
+
errors: [`No backup found for ${path}`]
|
|
1077
|
+
};
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
return {
|
|
1080
|
+
success: false,
|
|
1081
|
+
errors: [err instanceof Error ? err.message : String(err)]
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return {
|
|
1086
|
+
create,
|
|
1087
|
+
read,
|
|
1088
|
+
update,
|
|
1089
|
+
delete: del,
|
|
1090
|
+
restore
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/server/terminal.ts
|
|
1095
|
+
/**
|
|
1096
|
+
* Terminal session manager — spawns shell processes and buffers output
|
|
1097
|
+
* for polling-based retrieval by the xterm.js frontend.
|
|
1098
|
+
*
|
|
1099
|
+
* On macOS/Linux, uses Python's `pty` module to create a real pseudo-terminal.
|
|
1100
|
+
* This gives the shell (bash/zsh) a proper TTY, enabling:
|
|
1101
|
+
* - Input echo (characters appear as you type)
|
|
1102
|
+
* - Line editing (arrow keys, Ctrl+A/E, etc.)
|
|
1103
|
+
* - Tab completion
|
|
1104
|
+
* - Full TUI apps (vim, htop, etc.)
|
|
1105
|
+
*
|
|
1106
|
+
* Python3 is pre-installed on macOS (via Xcode CLT) and most Linux distros.
|
|
1107
|
+
* If Python3 is not available, falls back to direct shell spawn without a PTY
|
|
1108
|
+
* (commands still execute, but without echo or line editing).
|
|
1109
|
+
*
|
|
1110
|
+
* On Windows, cmd.exe/PowerShell work natively with pipe stdio — no PTY needed.
|
|
1111
|
+
*/
|
|
1112
|
+
/**
|
|
1113
|
+
* Python one-liner that creates a PTY and spawns the given command.
|
|
1114
|
+
* `pty.spawn` handles fork/exec/relay: the child gets the PTY slave as its
|
|
1115
|
+
* controlling terminal, while the parent relays between its own stdin/stdout
|
|
1116
|
+
* and the PTY master. This gives the shell a real TTY with full interactive
|
|
1117
|
+
* features (echo, readline, TUI support).
|
|
1118
|
+
*/
|
|
1119
|
+
const PTY_SCRIPT = "import pty, sys; pty.spawn(sys.argv[1:])";
|
|
1120
|
+
let _pythonAvailable = null;
|
|
1121
|
+
function isPythonAvailable() {
|
|
1122
|
+
if (_pythonAvailable !== null) return _pythonAvailable;
|
|
1123
|
+
if (process.platform === "win32") {
|
|
1124
|
+
_pythonAvailable = false;
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
try {
|
|
1128
|
+
execSync("python3 --version", {
|
|
1129
|
+
stdio: "ignore",
|
|
1130
|
+
timeout: 3e3
|
|
1131
|
+
});
|
|
1132
|
+
_pythonAvailable = true;
|
|
1133
|
+
} catch {
|
|
1134
|
+
_pythonAvailable = false;
|
|
1135
|
+
}
|
|
1136
|
+
return _pythonAvailable;
|
|
1137
|
+
}
|
|
1138
|
+
/** Warning messages from bash/zsh when running -i without a real TTY (fallback only). */
|
|
1139
|
+
const TTY_WARNING_RE = /^(?:bash|zsh):\s+(?:cannot set terminal process group|no job control in this shell)/;
|
|
1140
|
+
function createTerminalServer() {
|
|
1141
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1142
|
+
function getDefaultShell() {
|
|
1143
|
+
if (process.platform === "win32") return process.env.COMSPEC || "cmd.exe";
|
|
1144
|
+
return process.env.SHELL || "/bin/bash";
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Build the spawn command for the current platform.
|
|
1148
|
+
*
|
|
1149
|
+
* On macOS/Linux with Python3 available: wraps the shell in `python3 -c`
|
|
1150
|
+
* with a `pty.spawn` script so the shell gets a real PTY. Without Python3,
|
|
1151
|
+
* spawns the shell directly (degraded experience: no echo, no readline).
|
|
1152
|
+
* On Windows: spawns cmd.exe/PowerShell directly (pipes work natively).
|
|
1153
|
+
*/
|
|
1154
|
+
function buildSpawnCommand(shell, shellArgs) {
|
|
1155
|
+
if (process.platform === "win32") return {
|
|
1156
|
+
command: shell,
|
|
1157
|
+
args: shellArgs,
|
|
1158
|
+
usingPty: false
|
|
1159
|
+
};
|
|
1160
|
+
if (isPythonAvailable()) return {
|
|
1161
|
+
command: "python3",
|
|
1162
|
+
args: [
|
|
1163
|
+
"-c",
|
|
1164
|
+
PTY_SCRIPT,
|
|
1165
|
+
shell,
|
|
1166
|
+
...shellArgs
|
|
1167
|
+
],
|
|
1168
|
+
usingPty: true
|
|
1169
|
+
};
|
|
1170
|
+
return {
|
|
1171
|
+
command: shell,
|
|
1172
|
+
args: shellArgs,
|
|
1173
|
+
usingPty: false
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
function start(params) {
|
|
1177
|
+
const id = randomUUID();
|
|
1178
|
+
const { command, args, usingPty } = buildSpawnCommand(params.shell || getDefaultShell(), process.platform === "win32" ? [] : ["-i"]);
|
|
1179
|
+
const cols = params.cols || 80;
|
|
1180
|
+
const rows = params.rows || 24;
|
|
1181
|
+
const proc = spawn(command, args, {
|
|
1182
|
+
cwd: params.cwd,
|
|
1183
|
+
env: {
|
|
1184
|
+
...process.env,
|
|
1185
|
+
TERM: "xterm-256color",
|
|
1186
|
+
COLORTERM: "truecolor",
|
|
1187
|
+
FORCE_COLOR: "1",
|
|
1188
|
+
UBEAN_TERM_COLS: String(cols),
|
|
1189
|
+
UBEAN_TERM_ROWS: String(rows),
|
|
1190
|
+
LSCOLORS: "Gxfxcxdxbxegedabagacad",
|
|
1191
|
+
LS_COLORS: "di=34:ln=36:so=35:pi=33:ex=32:bd=34:cd=34:su=41;37:sg=41;37:tw=42;37:ow=42;37"
|
|
1192
|
+
},
|
|
1193
|
+
stdio: [
|
|
1194
|
+
"pipe",
|
|
1195
|
+
"pipe",
|
|
1196
|
+
"pipe"
|
|
1197
|
+
]
|
|
1198
|
+
});
|
|
1199
|
+
const session = {
|
|
1200
|
+
id,
|
|
1201
|
+
proc,
|
|
1202
|
+
cwd: params.cwd,
|
|
1203
|
+
cols,
|
|
1204
|
+
rows,
|
|
1205
|
+
buffer: "",
|
|
1206
|
+
exited: false,
|
|
1207
|
+
exitCode: null
|
|
1208
|
+
};
|
|
1209
|
+
if (!usingPty && process.platform !== "win32") session.buffer += "\x1B[33m⚠ Terminal running without PTY (python3 not found).\r\n Commands execute but input echo and line editing are disabled.\r\n Install python3 for full terminal support.\x1B[0m\r\n\r\n";
|
|
1210
|
+
proc.stdout?.on("data", (data) => {
|
|
1211
|
+
session.buffer += data.toString();
|
|
1212
|
+
});
|
|
1213
|
+
proc.stderr?.on("data", (data) => {
|
|
1214
|
+
const text = data.toString();
|
|
1215
|
+
if (usingPty) session.buffer += text;
|
|
1216
|
+
else {
|
|
1217
|
+
const lines = text.split("\n").filter((line) => !TTY_WARNING_RE.test(line.trim()));
|
|
1218
|
+
if (lines.length > 0) session.buffer += lines.join("\n");
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
proc.on("exit", (code) => {
|
|
1222
|
+
session.exited = true;
|
|
1223
|
+
session.exitCode = code;
|
|
1224
|
+
session.buffer += `\r\n\x1b[2m[Process exited with code ${code}]\x1b[0m\r\n`;
|
|
1225
|
+
});
|
|
1226
|
+
proc.on("error", (err) => {
|
|
1227
|
+
session.buffer += `\r\n\x1b[31m[Error: ${err.message}]\x1b[0m\r\n`;
|
|
1228
|
+
session.exited = true;
|
|
1229
|
+
session.exitCode = -1;
|
|
1230
|
+
});
|
|
1231
|
+
sessions.set(id, session);
|
|
1232
|
+
return { sessionId: id };
|
|
1233
|
+
}
|
|
1234
|
+
function input(sessionId, data) {
|
|
1235
|
+
const session = sessions.get(sessionId);
|
|
1236
|
+
if (!session || !session.proc || session.exited) return false;
|
|
1237
|
+
session.proc.stdin?.write(data);
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
function resize(sessionId, cols, rows) {
|
|
1241
|
+
const session = sessions.get(sessionId);
|
|
1242
|
+
if (!session) return false;
|
|
1243
|
+
session.cols = cols;
|
|
1244
|
+
session.rows = rows;
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
function poll(sessionId) {
|
|
1248
|
+
const session = sessions.get(sessionId);
|
|
1249
|
+
if (!session) return {
|
|
1250
|
+
data: "",
|
|
1251
|
+
exited: true,
|
|
1252
|
+
exitCode: -1
|
|
1253
|
+
};
|
|
1254
|
+
const data = session.buffer;
|
|
1255
|
+
session.buffer = "";
|
|
1256
|
+
return {
|
|
1257
|
+
data,
|
|
1258
|
+
exited: session.exited,
|
|
1259
|
+
exitCode: session.exitCode
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
function kill(sessionId) {
|
|
1263
|
+
const session = sessions.get(sessionId);
|
|
1264
|
+
if (!session) return false;
|
|
1265
|
+
if (session.proc && !session.exited) try {
|
|
1266
|
+
session.proc.kill("SIGTERM");
|
|
1267
|
+
} catch {}
|
|
1268
|
+
sessions.delete(sessionId);
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
function killAll() {
|
|
1272
|
+
for (const id of sessions.keys()) kill(id);
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
start,
|
|
1276
|
+
input,
|
|
1277
|
+
resize,
|
|
1278
|
+
poll,
|
|
1279
|
+
kill,
|
|
1280
|
+
killAll
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
//#endregion
|
|
1284
|
+
//#region ../../node_modules/.pnpm/devframe@0.7.14_cac@7.0.0_srvx@0.11.22_typescript@6.0.3/node_modules/devframe/dist/define-BLWPsH6y.mjs
|
|
1285
|
+
function createDefineWrapperWithContext() {
|
|
1286
|
+
return function defineRpcFunctionWithContext(definition) {
|
|
1287
|
+
return definition;
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
//#endregion
|
|
1291
|
+
//#region ../../node_modules/.pnpm/@vitejs+devtools-kit@0.4.8_@voidzero-dev+vite-plus-core@0.2.6_cac@7.0.0_srvx@0.11.22_typescript@6.0.3/node_modules/@vitejs/devtools-kit/dist/index.js
|
|
1292
|
+
const defineRpcFunction = createDefineWrapperWithContext();
|
|
1293
|
+
//#endregion
|
|
1294
|
+
//#region src/node/rpc/ai.ts
|
|
1295
|
+
/**
|
|
1296
|
+
* AI RPC functions — `ubean:ai:tools`, `ubean:ai:chat`, and `ubean:ai:chat-stream`.
|
|
1297
|
+
*
|
|
1298
|
+
* `ubean:ai:chat` — non-streaming chat (returns full response).
|
|
1299
|
+
* `ubean:ai:chat-stream` — streaming chat; pushes chunks to the
|
|
1300
|
+
* `ubean:ai:stream` sharedState key via the `onStreamChunk` callback
|
|
1301
|
+
* wired in `createAiServer`. The client subscribes to that sharedState
|
|
1302
|
+
* and filters by `requestId`.
|
|
1303
|
+
*/
|
|
1304
|
+
function createAiRpcFunctions(ai) {
|
|
1305
|
+
return [
|
|
1306
|
+
defineRpcFunction({
|
|
1307
|
+
name: "ubean:ai:tools",
|
|
1308
|
+
type: "query",
|
|
1309
|
+
setup: () => ({ handler: () => ai.getToolDefinitions() })
|
|
1310
|
+
}),
|
|
1311
|
+
defineRpcFunction({
|
|
1312
|
+
name: "ubean:ai:chat",
|
|
1313
|
+
type: "action",
|
|
1314
|
+
setup: () => ({ handler: (params) => ai.chat({
|
|
1315
|
+
messages: params.messages,
|
|
1316
|
+
apiKey: params.apiKey,
|
|
1317
|
+
apiBase: params.apiBase,
|
|
1318
|
+
model: params.model
|
|
1319
|
+
}) })
|
|
1320
|
+
}),
|
|
1321
|
+
defineRpcFunction({
|
|
1322
|
+
name: "ubean:ai:chat-stream",
|
|
1323
|
+
type: "action",
|
|
1324
|
+
setup: () => ({ handler: (params) => ai.chat({
|
|
1325
|
+
messages: params.messages,
|
|
1326
|
+
apiKey: params.apiKey,
|
|
1327
|
+
apiBase: params.apiBase,
|
|
1328
|
+
model: params.model,
|
|
1329
|
+
requestId: params.requestId
|
|
1330
|
+
}) })
|
|
1331
|
+
})
|
|
1332
|
+
];
|
|
1333
|
+
}
|
|
1334
|
+
//#endregion
|
|
1335
|
+
//#region src/node/rpc/crud.ts
|
|
1336
|
+
/**
|
|
1337
|
+
* CRUD RPC functions — `ubean:crud:create/read/update/delete/restore`.
|
|
1338
|
+
*
|
|
1339
|
+
* Each function delegates to the existing `createCrudServer` which
|
|
1340
|
+
* encapsulates all file-scaffolding, backup, and hook logic. The RPC
|
|
1341
|
+
* layer is purely a transport adapter.
|
|
1342
|
+
*/
|
|
1343
|
+
function createCrudRpcFunctions(crud) {
|
|
1344
|
+
return [
|
|
1345
|
+
defineRpcFunction({
|
|
1346
|
+
name: "ubean:crud:create",
|
|
1347
|
+
type: "action",
|
|
1348
|
+
setup: () => ({ handler: (params) => crud.create(params) })
|
|
1349
|
+
}),
|
|
1350
|
+
defineRpcFunction({
|
|
1351
|
+
name: "ubean:crud:read",
|
|
1352
|
+
type: "query",
|
|
1353
|
+
setup: () => ({ handler: (params) => crud.read(params) })
|
|
1354
|
+
}),
|
|
1355
|
+
defineRpcFunction({
|
|
1356
|
+
name: "ubean:crud:update",
|
|
1357
|
+
type: "action",
|
|
1358
|
+
setup: () => ({ handler: (params) => crud.update(params) })
|
|
1359
|
+
}),
|
|
1360
|
+
defineRpcFunction({
|
|
1361
|
+
name: "ubean:crud:delete",
|
|
1362
|
+
type: "action",
|
|
1363
|
+
setup: () => ({ handler: (params) => crud.delete(params) })
|
|
1364
|
+
}),
|
|
1365
|
+
defineRpcFunction({
|
|
1366
|
+
name: "ubean:crud:restore",
|
|
1367
|
+
type: "action",
|
|
1368
|
+
setup: () => ({ handler: (path) => crud.restore(path) })
|
|
1369
|
+
})
|
|
1370
|
+
];
|
|
1371
|
+
}
|
|
1372
|
+
//#endregion
|
|
1373
|
+
//#region src/node/rpc/info.ts
|
|
1374
|
+
/**
|
|
1375
|
+
* Info RPC functions — `ubean:get-info` and `ubean:get-env`.
|
|
1376
|
+
*
|
|
1377
|
+
* In the sharedState model, `ubean:get-info` is a fallback for clients
|
|
1378
|
+
* that haven't subscribed to the `ubean:info` state yet. Most clients
|
|
1379
|
+
* should use `client.sharedState.get('ubean:info')` instead.
|
|
1380
|
+
*/
|
|
1381
|
+
function createInfoRpcFunctions(opts) {
|
|
1382
|
+
return [defineRpcFunction({
|
|
1383
|
+
name: "ubean:get-info",
|
|
1384
|
+
type: "query",
|
|
1385
|
+
setup: () => ({ handler: () => opts.state.value() })
|
|
1386
|
+
}), defineRpcFunction({
|
|
1387
|
+
name: "ubean:get-env",
|
|
1388
|
+
type: "query",
|
|
1389
|
+
setup: () => ({ handler: () => maskSensitiveEnv(opts.getEnvData()) })
|
|
1390
|
+
})];
|
|
1391
|
+
}
|
|
1392
|
+
//#endregion
|
|
1393
|
+
//#region src/node/rpc/playground.ts
|
|
1394
|
+
/**
|
|
1395
|
+
* Playground RPC function — `ubean:playground:invoke`.
|
|
1396
|
+
*
|
|
1397
|
+
* Forwards an HTTP request to the Hono app in-process (no network
|
|
1398
|
+
* round-trip), mirroring the `internalFetch` / `callInternal` pattern
|
|
1399
|
+
* used elsewhere in ubean.
|
|
1400
|
+
*/
|
|
1401
|
+
function createPlaygroundRpcFunctions(opts) {
|
|
1402
|
+
return [defineRpcFunction({
|
|
1403
|
+
name: "ubean:playground:invoke",
|
|
1404
|
+
type: "action",
|
|
1405
|
+
setup: () => ({ handler: async (params) => {
|
|
1406
|
+
const app = opts.getApp?.();
|
|
1407
|
+
if (!app) return {
|
|
1408
|
+
status: 503,
|
|
1409
|
+
statusText: "Service Unavailable",
|
|
1410
|
+
body: { error: "App not available" },
|
|
1411
|
+
headers: {}
|
|
1412
|
+
};
|
|
1413
|
+
const url = `http://localhost${params.path}`;
|
|
1414
|
+
const headers = new Headers(params.headers || { "Content-Type": "application/json" });
|
|
1415
|
+
const hasBody = params.body !== void 0 && params.method !== "GET" && params.method !== "HEAD";
|
|
1416
|
+
const req = new Request(url, {
|
|
1417
|
+
method: params.method,
|
|
1418
|
+
headers,
|
|
1419
|
+
...hasBody ? { body: JSON.stringify(params.body) } : {}
|
|
1420
|
+
});
|
|
1421
|
+
const res = await app.fetch(req);
|
|
1422
|
+
const resHeaders = {};
|
|
1423
|
+
res.headers.forEach((v, k) => {
|
|
1424
|
+
resHeaders[k] = v;
|
|
1425
|
+
});
|
|
1426
|
+
const contentType = res.headers.get("content-type") || "";
|
|
1427
|
+
let body;
|
|
1428
|
+
if (contentType.includes("application/json")) body = await res.json();
|
|
1429
|
+
else body = await res.text();
|
|
1430
|
+
return {
|
|
1431
|
+
status: res.status,
|
|
1432
|
+
statusText: res.statusText,
|
|
1433
|
+
body,
|
|
1434
|
+
headers: resHeaders
|
|
1435
|
+
};
|
|
1436
|
+
} })
|
|
1437
|
+
})];
|
|
1438
|
+
}
|
|
1439
|
+
//#endregion
|
|
1440
|
+
//#region src/node/rpc/terminal.ts
|
|
1441
|
+
/**
|
|
1442
|
+
* Terminal RPC functions — `ubean:terminal:start/input/resize/poll/kill`.
|
|
1443
|
+
*
|
|
1444
|
+
* Delegates to `createTerminalServer` which manages shell processes and
|
|
1445
|
+
* output buffering. The client polls for output via `ubean:terminal:poll`.
|
|
1446
|
+
*/
|
|
1447
|
+
function createTerminalRpcFunctions(terminal) {
|
|
1448
|
+
return [
|
|
1449
|
+
defineRpcFunction({
|
|
1450
|
+
name: "ubean:terminal:start",
|
|
1451
|
+
type: "action",
|
|
1452
|
+
setup: () => ({ handler: (params) => Promise.resolve(terminal.start(params)) })
|
|
1453
|
+
}),
|
|
1454
|
+
defineRpcFunction({
|
|
1455
|
+
name: "ubean:terminal:input",
|
|
1456
|
+
type: "action",
|
|
1457
|
+
setup: () => ({ handler: (params) => Promise.resolve(terminal.input(params.sessionId, params.data)) })
|
|
1458
|
+
}),
|
|
1459
|
+
defineRpcFunction({
|
|
1460
|
+
name: "ubean:terminal:resize",
|
|
1461
|
+
type: "action",
|
|
1462
|
+
setup: () => ({ handler: (params) => Promise.resolve(terminal.resize(params.sessionId, params.cols, params.rows)) })
|
|
1463
|
+
}),
|
|
1464
|
+
defineRpcFunction({
|
|
1465
|
+
name: "ubean:terminal:poll",
|
|
1466
|
+
type: "query",
|
|
1467
|
+
setup: () => ({ handler: (params) => Promise.resolve(terminal.poll(params.sessionId)) })
|
|
1468
|
+
}),
|
|
1469
|
+
defineRpcFunction({
|
|
1470
|
+
name: "ubean:terminal:kill",
|
|
1471
|
+
type: "action",
|
|
1472
|
+
setup: () => ({ handler: (params) => Promise.resolve(terminal.kill(params.sessionId)) })
|
|
1473
|
+
})
|
|
1474
|
+
];
|
|
1475
|
+
}
|
|
1476
|
+
//#endregion
|
|
1477
|
+
//#region src/node/rpc/index.ts
|
|
1478
|
+
/**
|
|
1479
|
+
* Returns an array of `defineRpcFunction` definitions. The return type is
|
|
1480
|
+
* intentionally loose (`any[]`) because the DTK `ctx.rpc.register()` accepts
|
|
1481
|
+
* definitions parameterized on `ViteDevToolsNodeContext`, and the exact
|
|
1482
|
+
* generic instantiation varies per function — the individual functions are
|
|
1483
|
+
* already type-checked at their definition site.
|
|
1484
|
+
*/
|
|
1485
|
+
function createAllRpcFunctions(deps) {
|
|
1486
|
+
return [
|
|
1487
|
+
...createInfoRpcFunctions({
|
|
1488
|
+
state: deps.state,
|
|
1489
|
+
getEnvData: deps.getEnvData
|
|
1490
|
+
}),
|
|
1491
|
+
...createCrudRpcFunctions(deps.crud),
|
|
1492
|
+
...createAiRpcFunctions(deps.ai),
|
|
1493
|
+
...createPlaygroundRpcFunctions({ getApp: deps.getApp }),
|
|
1494
|
+
...createTerminalRpcFunctions(deps.terminal)
|
|
1495
|
+
];
|
|
1496
|
+
}
|
|
1497
|
+
//#endregion
|
|
1498
|
+
//#region src/node/state.ts
|
|
1499
|
+
/**
|
|
1500
|
+
* SharedState management for the DTK integration.
|
|
1501
|
+
*
|
|
1502
|
+
* Replaces the old 3-second polling pattern: the server builds a
|
|
1503
|
+
* `DevToolsInfo` snapshot and pushes patches to all connected clients
|
|
1504
|
+
* via `ctx.rpc.sharedState`.
|
|
1505
|
+
*/
|
|
1506
|
+
/** SharedState key for the ubean devtools info snapshot. */
|
|
1507
|
+
const UBEAN_INFO_STATE_KEY = "ubean:info";
|
|
1508
|
+
/** Build a fresh `DevToolsInfo` from scan + config metadata. */
|
|
1509
|
+
function buildDevToolsInfo(opts) {
|
|
1510
|
+
const { scan, configMeta, customTabs, ai, startTime, envData } = opts;
|
|
1511
|
+
const rootDir = configMeta?.rootDir || "";
|
|
1512
|
+
const srcDir = configMeta?.srcDir || (rootDir ? join(rootDir, "src") : "src");
|
|
1513
|
+
const dirs = configMeta?.dir ?? {};
|
|
1514
|
+
const srcRel = rootDir ? relative(rootDir, srcDir).replace(/\\/g, "/") || "src" : "src";
|
|
1515
|
+
const routesDir = dirs.routes || "routes";
|
|
1516
|
+
const pagesDir = dirs.pages || "pages";
|
|
1517
|
+
const middlewareDir = dirs.middleware || "middleware";
|
|
1518
|
+
const layoutsDir = dirs.layouts || "layouts";
|
|
1519
|
+
const cronsDir = dirs.crons || "crons";
|
|
1520
|
+
const routesPrefix = `${srcRel}/${routesDir}`;
|
|
1521
|
+
const pagesPrefix = `${srcRel}/${pagesDir}`;
|
|
1522
|
+
const middlewarePrefix = `${srcRel}/${middlewareDir}`;
|
|
1523
|
+
const layoutsPrefix = `${srcRel}/${layoutsDir}`;
|
|
1524
|
+
const cronsPrefix = `${srcRel}/${cronsDir}`;
|
|
1525
|
+
const makeFilePath = (fullPath, relPath, prefix) => {
|
|
1526
|
+
if (!fullPath) return "";
|
|
1527
|
+
if (rootDir) {
|
|
1528
|
+
const rel = relative(rootDir, fullPath).replace(/\\/g, "/");
|
|
1529
|
+
if (rel && !rel.startsWith("..")) return rel;
|
|
1530
|
+
}
|
|
1531
|
+
return `${prefix}/${relPath.replace(/\\/g, "/")}`;
|
|
1532
|
+
};
|
|
1533
|
+
const routes = scan ? scan.apiRoutes.map((r) => ({
|
|
1534
|
+
method: (r.method || "GET").toUpperCase(),
|
|
1535
|
+
path: r.route,
|
|
1536
|
+
filePath: makeFilePath(r.fullPath, r.relativePath, routesPrefix)
|
|
1537
|
+
})) : [];
|
|
1538
|
+
const defaultLayoutName = scan?.layouts.find((l) => l.isDefault)?.name;
|
|
1539
|
+
const pagesList = scan ? scan.pages.filter((p) => !p.isReuse).map((p) => ({
|
|
1540
|
+
path: p.route,
|
|
1541
|
+
name: p.name,
|
|
1542
|
+
filePath: makeFilePath(p.fullPath, p.relativePath, pagesPrefix),
|
|
1543
|
+
layout: p.layout === false ? void 0 : p.layout || defaultLayoutName || void 0
|
|
1544
|
+
})) : [];
|
|
1545
|
+
const middlewaresList = scan ? scan.middlewares.map((m) => ({
|
|
1546
|
+
path: m.global ? "*" : m.relativePath,
|
|
1547
|
+
filePath: makeFilePath(m.fullPath, m.relativePath, middlewarePrefix),
|
|
1548
|
+
global: m.global
|
|
1549
|
+
})) : [];
|
|
1550
|
+
const layoutsList = scan ? scan.layouts.map((l) => ({
|
|
1551
|
+
name: l.name,
|
|
1552
|
+
path: l.path,
|
|
1553
|
+
filePath: makeFilePath(l.fullPath, l.relativePath, layoutsPrefix),
|
|
1554
|
+
isDefault: l.isDefault
|
|
1555
|
+
})) : [];
|
|
1556
|
+
const cronsList = scan ? scan.crons.map((c) => ({
|
|
1557
|
+
name: c.name,
|
|
1558
|
+
filePath: makeFilePath(c.fullPath, c.relativePath, cronsPrefix)
|
|
1559
|
+
})) : [];
|
|
1560
|
+
return {
|
|
1561
|
+
version: "0.0.1",
|
|
1562
|
+
startTime,
|
|
1563
|
+
config: configMeta ? {
|
|
1564
|
+
preset: configMeta.preset,
|
|
1565
|
+
rootDir: configMeta.rootDir,
|
|
1566
|
+
srcDir: configMeta.srcDir,
|
|
1567
|
+
dir: configMeta.dir
|
|
1568
|
+
} : {},
|
|
1569
|
+
env: maskSensitiveEnv(envData),
|
|
1570
|
+
pages: pagesList.length,
|
|
1571
|
+
apiRoutes: routes.length,
|
|
1572
|
+
middleware: middlewaresList.length,
|
|
1573
|
+
layouts: layoutsList.length,
|
|
1574
|
+
crons: cronsList.length,
|
|
1575
|
+
presets: configMeta ? [configMeta.preset] : [],
|
|
1576
|
+
routes,
|
|
1577
|
+
pagesList,
|
|
1578
|
+
middlewaresList,
|
|
1579
|
+
layoutsList,
|
|
1580
|
+
cronsList,
|
|
1581
|
+
customTabs,
|
|
1582
|
+
openAPI: configMeta?.openAPI ?? { enabled: false },
|
|
1583
|
+
database: configMeta?.database,
|
|
1584
|
+
ai: {
|
|
1585
|
+
enabled: !!(ai?.apiKey || process.env.DEEPSEEK_API_KEY || process.env.UBEAN_AI_API_KEY || process.env.OPENAI_API_KEY),
|
|
1586
|
+
provider: ai?.apiBase?.includes("deepseek") ? "deepseek" : ai?.apiBase?.includes("anthropic") ? "anthropic" : "deepseek",
|
|
1587
|
+
model: ai?.model || (ai?.apiBase?.includes("deepseek") || !ai?.apiBase ? "deepseek-chat" : ai.model)
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
/** Initial empty state (used before scan data arrives). */
|
|
1592
|
+
function emptyDevToolsInfo(startTime) {
|
|
1593
|
+
return {
|
|
1594
|
+
version: "0.0.1",
|
|
1595
|
+
startTime,
|
|
1596
|
+
config: {},
|
|
1597
|
+
env: {},
|
|
1598
|
+
pages: 0,
|
|
1599
|
+
apiRoutes: 0,
|
|
1600
|
+
middleware: 0,
|
|
1601
|
+
layouts: 0,
|
|
1602
|
+
crons: 0,
|
|
1603
|
+
presets: [],
|
|
1604
|
+
routes: [],
|
|
1605
|
+
pagesList: [],
|
|
1606
|
+
middlewaresList: [],
|
|
1607
|
+
layoutsList: [],
|
|
1608
|
+
cronsList: [],
|
|
1609
|
+
customTabs: [],
|
|
1610
|
+
ai: { enabled: false }
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Create or retrieve the `ubean:info` shared state.
|
|
1615
|
+
* Called once during `devtools.setup`.
|
|
1616
|
+
*/
|
|
1617
|
+
async function initSharedState(ctx, initialValue) {
|
|
1618
|
+
return ctx.rpc.sharedState.get(UBEAN_INFO_STATE_KEY, { initialValue });
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Push a fresh `DevToolsInfo` into the shared state, triggering
|
|
1622
|
+
* patch-sync to all connected clients.
|
|
1623
|
+
*/
|
|
1624
|
+
function refreshSharedState(state, info) {
|
|
1625
|
+
state.mutate((draft) => {
|
|
1626
|
+
Object.assign(draft, info);
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
//#endregion
|
|
1630
|
+
//#region src/node/index.ts
|
|
1631
|
+
/**
|
|
1632
|
+
* Path to the pre-built client SPA. Resolved relative to the built server
|
|
1633
|
+
* bundle (`dist/index.mjs`), so this points to `dist/client/` at runtime.
|
|
1634
|
+
*/
|
|
1635
|
+
const CLIENT_DIST = resolve(dirname(fileURLToPath(import.meta.url)), "client");
|
|
1636
|
+
/**
|
|
1637
|
+
* Ubean DevTools as a Vite plugin.
|
|
1638
|
+
*
|
|
1639
|
+
* Registers a dock entry (iframe pointing at the pre-built SPA), hosts the
|
|
1640
|
+
* SPA's static assets, and wires up the type-safe RPC + sharedState used by
|
|
1641
|
+
* the SPA. This replaces the previous Hono-middleware-based transport with
|
|
1642
|
+
* the Vite DevTools Kit (DTK) birpc + shared-state machinery.
|
|
1643
|
+
*/
|
|
1644
|
+
function ubeanDevtoolsPlugin(options = { getCwd: () => process.cwd() }) {
|
|
1645
|
+
return {
|
|
1646
|
+
name: "ubean:devtools",
|
|
1647
|
+
devtools: { async setup(ctx) {
|
|
1648
|
+
console.log("[ubean:devtools] devtools.setup hook fired — DTK integration active");
|
|
1649
|
+
ctx.views.hostStatic("/_devtools/", CLIENT_DIST);
|
|
1650
|
+
const viteServer = ctx.viteServer;
|
|
1651
|
+
if (viteServer?.middlewares) {
|
|
1652
|
+
const serveConnectionMeta = (req, res) => {
|
|
1653
|
+
const targetUrl = `${req.headers["x-forwarded-proto"] || "http"}://${req.headers.host || "localhost:9527"}/__devtools/__connection.json`;
|
|
1654
|
+
fetch(targetUrl).then(async (metaRes) => {
|
|
1655
|
+
const meta = await metaRes.json();
|
|
1656
|
+
if (meta.websocket && typeof meta.websocket === "object") meta.websocket = {
|
|
1657
|
+
...meta.websocket,
|
|
1658
|
+
path: "/__devtools/__ws"
|
|
1659
|
+
};
|
|
1660
|
+
res.setHeader("Content-Type", "application/json");
|
|
1661
|
+
res.end(JSON.stringify(meta));
|
|
1662
|
+
}).catch(() => {
|
|
1663
|
+
res.statusCode = 500;
|
|
1664
|
+
res.setHeader("Content-Type", "application/json");
|
|
1665
|
+
res.end(JSON.stringify({ error: "Failed to resolve connection meta" }));
|
|
1666
|
+
});
|
|
1667
|
+
};
|
|
1668
|
+
viteServer.middlewares.use("/_devtools/__connection.json", serveConnectionMeta);
|
|
1669
|
+
viteServer.middlewares.use("/__connection.json", serveConnectionMeta);
|
|
1670
|
+
}
|
|
1671
|
+
const SPA_BASE = "/_devtools/index.html";
|
|
1672
|
+
const dockEntries = [
|
|
1673
|
+
{
|
|
1674
|
+
id: "ubean:overview",
|
|
1675
|
+
title: "Overview",
|
|
1676
|
+
icon: "lucide:layout-dashboard",
|
|
1677
|
+
url: `${SPA_BASE}#/overview`,
|
|
1678
|
+
order: 100
|
|
1679
|
+
},
|
|
1680
|
+
{
|
|
1681
|
+
id: "ubean:pages",
|
|
1682
|
+
title: "Pages",
|
|
1683
|
+
icon: "lucide:file-text",
|
|
1684
|
+
url: `${SPA_BASE}#/pages`,
|
|
1685
|
+
order: 95
|
|
1686
|
+
},
|
|
1687
|
+
{
|
|
1688
|
+
id: "ubean:api",
|
|
1689
|
+
title: "API",
|
|
1690
|
+
icon: "lucide:send",
|
|
1691
|
+
url: `${SPA_BASE}#/api`,
|
|
1692
|
+
order: 90
|
|
1693
|
+
},
|
|
1694
|
+
{
|
|
1695
|
+
id: "ubean:middleware",
|
|
1696
|
+
title: "Middleware",
|
|
1697
|
+
icon: "lucide:layers",
|
|
1698
|
+
url: `${SPA_BASE}#/middleware`,
|
|
1699
|
+
order: 85
|
|
1700
|
+
},
|
|
1701
|
+
{
|
|
1702
|
+
id: "ubean:crons",
|
|
1703
|
+
title: "Crons",
|
|
1704
|
+
icon: "lucide:clock",
|
|
1705
|
+
url: `${SPA_BASE}#/crons`,
|
|
1706
|
+
order: 80
|
|
1707
|
+
},
|
|
1708
|
+
{
|
|
1709
|
+
id: "ubean:config",
|
|
1710
|
+
title: "Config",
|
|
1711
|
+
icon: "lucide:settings",
|
|
1712
|
+
url: `${SPA_BASE}#/config`,
|
|
1713
|
+
order: 75
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
id: "ubean:env",
|
|
1717
|
+
title: "Env",
|
|
1718
|
+
icon: "lucide:terminal",
|
|
1719
|
+
url: `${SPA_BASE}#/env`,
|
|
1720
|
+
order: 70
|
|
1721
|
+
},
|
|
1722
|
+
{
|
|
1723
|
+
id: "ubean:api-docs",
|
|
1724
|
+
title: "API Docs",
|
|
1725
|
+
icon: "lucide:book-open",
|
|
1726
|
+
url: `${SPA_BASE}#/api-docs`,
|
|
1727
|
+
order: 65
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
id: "ubean:database",
|
|
1731
|
+
title: "Database",
|
|
1732
|
+
icon: "lucide:database",
|
|
1733
|
+
url: `${SPA_BASE}#/database`,
|
|
1734
|
+
order: 60
|
|
1735
|
+
},
|
|
1736
|
+
{
|
|
1737
|
+
id: "ubean:terminal",
|
|
1738
|
+
title: "Terminal",
|
|
1739
|
+
icon: "lucide:square-terminal",
|
|
1740
|
+
url: `${SPA_BASE}#/terminal`,
|
|
1741
|
+
order: 58
|
|
1742
|
+
},
|
|
1743
|
+
{
|
|
1744
|
+
id: "ubean:ai",
|
|
1745
|
+
title: "AI",
|
|
1746
|
+
icon: "lucide:sparkles",
|
|
1747
|
+
url: `${SPA_BASE}#/ai`,
|
|
1748
|
+
order: 55
|
|
1749
|
+
}
|
|
1750
|
+
];
|
|
1751
|
+
for (const entry of dockEntries) ctx.docks.register({
|
|
1752
|
+
id: entry.id,
|
|
1753
|
+
title: entry.title,
|
|
1754
|
+
icon: entry.icon,
|
|
1755
|
+
type: "iframe",
|
|
1756
|
+
url: entry.url,
|
|
1757
|
+
category: "framework",
|
|
1758
|
+
defaultOrder: entry.order
|
|
1759
|
+
});
|
|
1760
|
+
const startTime = Date.now();
|
|
1761
|
+
const customTabs = options.getCustomTabs?.() ?? [];
|
|
1762
|
+
const envData = snapshotEnv(options.getCwd());
|
|
1763
|
+
const initialInfo = options.getScanResult ? buildDevToolsInfo({
|
|
1764
|
+
scan: options.getScanResult(),
|
|
1765
|
+
configMeta: options.getConfigMeta?.() ?? null,
|
|
1766
|
+
customTabs,
|
|
1767
|
+
ai: options.ai,
|
|
1768
|
+
startTime,
|
|
1769
|
+
envData
|
|
1770
|
+
}) : emptyDevToolsInfo(startTime);
|
|
1771
|
+
for (const tab of customTabs) ctx.docks.register({
|
|
1772
|
+
id: `ubean:custom:${tab.id}`,
|
|
1773
|
+
title: tab.label,
|
|
1774
|
+
icon: tab.icon || "lucide:plugin",
|
|
1775
|
+
type: "iframe",
|
|
1776
|
+
url: tab.src,
|
|
1777
|
+
category: "framework",
|
|
1778
|
+
defaultOrder: 50
|
|
1779
|
+
});
|
|
1780
|
+
const state = await initSharedState(ctx, initialInfo);
|
|
1781
|
+
const aiStreamState = await ctx.rpc.sharedState.get("ubean:ai:stream", { initialValue: {
|
|
1782
|
+
requestId: "",
|
|
1783
|
+
text: "",
|
|
1784
|
+
done: true
|
|
1785
|
+
} });
|
|
1786
|
+
const hooks = createDevToolsHooks();
|
|
1787
|
+
const crud = createCrudServer({
|
|
1788
|
+
cwd: options.getCwd(),
|
|
1789
|
+
hooks,
|
|
1790
|
+
scaffoldOps: options.scaffoldOps,
|
|
1791
|
+
getEnv: () => envData,
|
|
1792
|
+
setEnv: (env) => {
|
|
1793
|
+
Object.keys(envData).forEach((k) => delete envData[k]);
|
|
1794
|
+
Object.assign(envData, env);
|
|
1795
|
+
},
|
|
1796
|
+
getConfig: () => options.getConfigMeta?.() ?? {},
|
|
1797
|
+
onFileChange: async () => {
|
|
1798
|
+
if (options.triggerRescan) await options.triggerRescan();
|
|
1799
|
+
}
|
|
1800
|
+
});
|
|
1801
|
+
const fns = createAllRpcFunctions({
|
|
1802
|
+
state,
|
|
1803
|
+
getEnvData: () => envData,
|
|
1804
|
+
crud,
|
|
1805
|
+
ai: createAiServer(crud, () => state.value(), (chunk) => {
|
|
1806
|
+
aiStreamState.mutate(() => chunk);
|
|
1807
|
+
}),
|
|
1808
|
+
terminal: createTerminalServer(),
|
|
1809
|
+
getApp: options.getApp
|
|
1810
|
+
});
|
|
1811
|
+
for (const fn of fns) ctx.rpc.register(fn);
|
|
1812
|
+
const refresh = () => {
|
|
1813
|
+
const info = options.getScanResult ? buildDevToolsInfo({
|
|
1814
|
+
scan: options.getScanResult(),
|
|
1815
|
+
configMeta: options.getConfigMeta?.() ?? null,
|
|
1816
|
+
customTabs: options.getCustomTabs?.() ?? [],
|
|
1817
|
+
ai: options.ai,
|
|
1818
|
+
startTime,
|
|
1819
|
+
envData
|
|
1820
|
+
}) : initialInfo;
|
|
1821
|
+
refreshSharedState(state, info);
|
|
1822
|
+
};
|
|
1823
|
+
options.registerRefresh?.(refresh);
|
|
1824
|
+
} }
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Parse a single .env file and return the key/value pairs.
|
|
1829
|
+
* - Ignores blank lines and `#` comments.
|
|
1830
|
+
* - Strips surrounding single/double quotes from values.
|
|
1831
|
+
*/
|
|
1832
|
+
function parseEnvFile(filePath) {
|
|
1833
|
+
if (!existsSync(filePath)) return {};
|
|
1834
|
+
let content;
|
|
1835
|
+
try {
|
|
1836
|
+
content = readFileSync(filePath, "utf-8");
|
|
1837
|
+
} catch {
|
|
1838
|
+
return {};
|
|
1839
|
+
}
|
|
1840
|
+
const env = {};
|
|
1841
|
+
for (const line of content.split("\n")) {
|
|
1842
|
+
const trimmed = line.trim();
|
|
1843
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1844
|
+
const eqIndex = trimmed.indexOf("=");
|
|
1845
|
+
if (eqIndex === -1) continue;
|
|
1846
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
1847
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
1848
|
+
const hashIndex = value.search(/\s+#.*$/);
|
|
1849
|
+
if (hashIndex !== -1 && !value.startsWith("\"") && !value.startsWith("'")) value = value.slice(0, hashIndex).trim();
|
|
1850
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
1851
|
+
env[key] = value;
|
|
1852
|
+
}
|
|
1853
|
+
return env;
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Snapshot env vars relevant to the ubean app.
|
|
1857
|
+
*
|
|
1858
|
+
* Instead of dumping all of `process.env` (which includes noisy system vars
|
|
1859
|
+
* like PATH, HOME, SHELL, LANG, TERM, etc.), we only collect:
|
|
1860
|
+
* 1. Vars defined in .env / .env.local / .env.development / .env.development.local
|
|
1861
|
+
* 2. UBEAN_* prefixed vars from process.env (framework/CLI vars set by shell)
|
|
1862
|
+
*
|
|
1863
|
+
* Values from process.env override .env file values (shell overrides take
|
|
1864
|
+
* precedence), matching Vite's env loading semantics.
|
|
1865
|
+
*/
|
|
1866
|
+
function snapshotEnv(cwd) {
|
|
1867
|
+
const envFiles = [
|
|
1868
|
+
".env",
|
|
1869
|
+
".env.local",
|
|
1870
|
+
".env.development",
|
|
1871
|
+
".env.development.local"
|
|
1872
|
+
];
|
|
1873
|
+
const env = {};
|
|
1874
|
+
for (const file of envFiles) Object.assign(env, parseEnvFile(resolve(cwd, file)));
|
|
1875
|
+
for (const [key, value] of Object.entries(process.env)) if (typeof value === "string" && (key in env || key.startsWith("UBEAN_"))) env[key] = value;
|
|
1876
|
+
return env;
|
|
1877
|
+
}
|
|
1878
|
+
//#endregion
|
|
1879
|
+
//#region src/define-tab.ts
|
|
1880
|
+
const customTabs = [];
|
|
1881
|
+
function defineDevToolsTab(tab) {
|
|
1882
|
+
const exists = customTabs.findIndex((t) => t.id === tab.id);
|
|
1883
|
+
if (exists !== -1) customTabs[exists] = tab;
|
|
1884
|
+
else customTabs.push(tab);
|
|
1885
|
+
return tab;
|
|
1886
|
+
}
|
|
1887
|
+
function getCustomTabs() {
|
|
1888
|
+
return [...customTabs];
|
|
1889
|
+
}
|
|
1890
|
+
function clearCustomTabs() {
|
|
1891
|
+
customTabs.length = 0;
|
|
1892
|
+
}
|
|
1893
|
+
//#endregion
|
|
1894
|
+
export { UBEAN_INFO_STATE_KEY, buildDevToolsInfo, clearCustomTabs, createAiServer, createAllRpcFunctions, createCrudServer, createDevToolsHooks, createTerminalServer, defineDevToolsTab, defineRpcFunction, emptyDevToolsInfo, getCustomTabs, maskSensitiveEnv, ubeanDevtoolsPlugin };
|