@xcanwin/manyoyo 4.1.1 → 4.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/manyoyo.js +325 -1016
- package/docker/manyoyo.Dockerfile +36 -39
- package/lib/agent-resume.js +72 -0
- package/lib/container-run.js +39 -0
- package/lib/image-build.js +323 -0
- package/lib/init-config.js +401 -0
- package/lib/web/frontend/app.css +420 -190
- package/lib/web/frontend/app.html +71 -4
- package/lib/web/frontend/app.js +840 -136
- package/lib/web/frontend/login.css +77 -63
- package/lib/web/server.js +757 -128
- package/package.json +2 -2
package/lib/web/server.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const crypto = require('crypto');
|
|
8
8
|
const http = require('http');
|
|
9
9
|
const WebSocket = require('ws');
|
|
10
|
+
const JSON5 = require('json5');
|
|
11
|
+
const { buildContainerRunArgs } = require('../container-run');
|
|
10
12
|
|
|
11
13
|
const WEB_HISTORY_MAX_MESSAGES = 500;
|
|
12
14
|
const WEB_OUTPUT_MAX_CHARS = 16000;
|
|
@@ -19,6 +21,39 @@ const WEB_TERMINAL_MIN_ROWS = 12;
|
|
|
19
21
|
const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
|
|
20
22
|
const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
|
|
21
23
|
const FRONTEND_DIR = path.join(__dirname, 'frontend');
|
|
24
|
+
const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
25
|
+
const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
|
|
26
|
+
|
|
27
|
+
const YOLO_COMMAND_MAP = {
|
|
28
|
+
claude: 'IS_SANDBOX=1 claude --dangerously-skip-permissions',
|
|
29
|
+
cc: 'IS_SANDBOX=1 claude --dangerously-skip-permissions',
|
|
30
|
+
c: 'IS_SANDBOX=1 claude --dangerously-skip-permissions',
|
|
31
|
+
gemini: 'gemini --yolo',
|
|
32
|
+
gm: 'gemini --yolo',
|
|
33
|
+
g: 'gemini --yolo',
|
|
34
|
+
codex: 'codex --dangerously-bypass-approvals-and-sandbox',
|
|
35
|
+
cx: 'codex --dangerously-bypass-approvals-and-sandbox',
|
|
36
|
+
opencode: 'OPENCODE_PERMISSION=\'{"*":"allow"}\' opencode',
|
|
37
|
+
oc: 'OPENCODE_PERMISSION=\'{"*":"allow"}\' opencode'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const DEFAULT_WEB_CONFIG_TEMPLATE = `{
|
|
41
|
+
// MANYOYO 全局配置文件(JSON5)
|
|
42
|
+
"containerName": "my-dev",
|
|
43
|
+
"hostPath": "/path/to/your/project",
|
|
44
|
+
"containerPath": "/path/to/your/project",
|
|
45
|
+
"imageName": "localhost/xcanwin/manyoyo",
|
|
46
|
+
"imageVersion": "1.7.4-common",
|
|
47
|
+
"containerMode": "common",
|
|
48
|
+
"shellPrefix": "",
|
|
49
|
+
"shell": "",
|
|
50
|
+
"shellSuffix": "",
|
|
51
|
+
"yolo": "",
|
|
52
|
+
"env": {},
|
|
53
|
+
"envFile": [],
|
|
54
|
+
"volumes": []
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
22
57
|
|
|
23
58
|
let XTERM_JS_FILE = null;
|
|
24
59
|
let XTERM_CSS_FILE = null;
|
|
@@ -217,6 +252,464 @@ function getWebAuthClearCookie() {
|
|
|
217
252
|
return `${WEB_AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
218
253
|
}
|
|
219
254
|
|
|
255
|
+
function getDefaultWebConfigPath() {
|
|
256
|
+
return path.join(os.homedir(), '.manyoyo', 'manyoyo.json');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function hasOwn(obj, key) {
|
|
260
|
+
return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function toPlainObject(value) {
|
|
264
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function pickFirstString() {
|
|
271
|
+
for (let i = 0; i < arguments.length; i += 1) {
|
|
272
|
+
const value = arguments[i];
|
|
273
|
+
if (typeof value === 'string') {
|
|
274
|
+
const trimmed = value.trim();
|
|
275
|
+
if (trimmed) {
|
|
276
|
+
return trimmed;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return '';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolveNowTemplate(value, formatDate) {
|
|
284
|
+
if (typeof value !== 'string') {
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
const nowText = typeof formatDate === 'function' ? formatDate() : '';
|
|
288
|
+
return value.replace(/\{now\}|\$\{now\}/g, nowText);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function validateContainerNameStrict(containerName) {
|
|
292
|
+
if (!SAFE_CONTAINER_NAME_PATTERN.test(containerName)) {
|
|
293
|
+
throw new Error(`containerName 非法: ${containerName}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function validateImageVersionStrict(imageVersion) {
|
|
298
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(imageVersion)) {
|
|
299
|
+
throw new Error(`imageVersion 非法: ${imageVersion}`);
|
|
300
|
+
}
|
|
301
|
+
if (!IMAGE_VERSION_TAG_PATTERN.test(imageVersion)) {
|
|
302
|
+
throw new Error(`imageVersion 格式必须为 <x.y.z-后缀>,例如 1.7.4-common。当前值: ${imageVersion}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function validateWebHostPath(hostPath) {
|
|
307
|
+
if (typeof hostPath !== 'string' || !hostPath.trim()) {
|
|
308
|
+
throw new Error('hostPath 不能为空');
|
|
309
|
+
}
|
|
310
|
+
if (!fs.existsSync(hostPath)) {
|
|
311
|
+
throw new Error(`宿主机路径不存在: ${hostPath}`);
|
|
312
|
+
}
|
|
313
|
+
const realHostPath = fs.realpathSync(hostPath);
|
|
314
|
+
const homeDir = process.env.HOME || os.homedir() || '/home';
|
|
315
|
+
if (realHostPath === '/' || realHostPath === '/home' || realHostPath === homeDir) {
|
|
316
|
+
throw new Error('不允许挂载根目录或home目录。');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function parseEnvEntry(entryText) {
|
|
321
|
+
const text = String(entryText || '');
|
|
322
|
+
const idx = text.indexOf('=');
|
|
323
|
+
if (idx <= 0) {
|
|
324
|
+
throw new Error(`env 格式应为 KEY=VALUE: ${text}`);
|
|
325
|
+
}
|
|
326
|
+
const key = text.slice(0, idx);
|
|
327
|
+
const value = text.slice(idx + 1);
|
|
328
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
329
|
+
throw new Error(`env key 非法: ${key}`);
|
|
330
|
+
}
|
|
331
|
+
if (/[\r\n\0]/.test(value) || /[;&|`$<>]/.test(value)) {
|
|
332
|
+
throw new Error(`env value 含非法字符: ${key}`);
|
|
333
|
+
}
|
|
334
|
+
return { key, value };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function normalizeEnvMap(envConfig, sourceLabel) {
|
|
338
|
+
if (envConfig === undefined || envConfig === null) {
|
|
339
|
+
return {};
|
|
340
|
+
}
|
|
341
|
+
if (typeof envConfig !== 'object' || Array.isArray(envConfig)) {
|
|
342
|
+
throw new Error(`${sourceLabel} 必须是对象(map)`);
|
|
343
|
+
}
|
|
344
|
+
const result = {};
|
|
345
|
+
for (const [key, rawValue] of Object.entries(envConfig)) {
|
|
346
|
+
if (rawValue !== null && !['string', 'number', 'boolean'].includes(typeof rawValue)) {
|
|
347
|
+
throw new Error(`${sourceLabel}.${key} 必须是 string/number/boolean/null`);
|
|
348
|
+
}
|
|
349
|
+
const parsed = parseEnvEntry(`${key}=${rawValue === null ? '' : String(rawValue)}`);
|
|
350
|
+
result[parsed.key] = parsed.value;
|
|
351
|
+
}
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function normalizeStringArray(value, sourceLabel) {
|
|
356
|
+
if (value === undefined || value === null) {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
if (!Array.isArray(value)) {
|
|
360
|
+
throw new Error(`${sourceLabel} 必须是数组`);
|
|
361
|
+
}
|
|
362
|
+
return value
|
|
363
|
+
.map(item => String(item || '').trim())
|
|
364
|
+
.filter(Boolean);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseEnvFileToArgs(filePath) {
|
|
368
|
+
if (!path.isAbsolute(filePath)) {
|
|
369
|
+
throw new Error(`envFile 仅支持绝对路径: ${filePath}`);
|
|
370
|
+
}
|
|
371
|
+
if (!fs.existsSync(filePath)) {
|
|
372
|
+
throw new Error(`未找到环境文件: ${filePath}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
376
|
+
const args = [];
|
|
377
|
+
const lines = content.split('\n');
|
|
378
|
+
|
|
379
|
+
for (let line of lines) {
|
|
380
|
+
const match = line.match(/^(?:export\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/);
|
|
381
|
+
if (!match) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const key = match[1];
|
|
386
|
+
let value = match[2].trim();
|
|
387
|
+
if (/[\r\n\0]/.test(value)) continue;
|
|
388
|
+
if (/[\$\(\)\`\|\&\*\{\};<>]/.test(value)) continue;
|
|
389
|
+
if (/^\(/.test(value)) continue;
|
|
390
|
+
|
|
391
|
+
if (/^"(.*)"$/.test(value)) {
|
|
392
|
+
value = value.slice(1, -1);
|
|
393
|
+
} else if (/^'(.*)'$/.test(value)) {
|
|
394
|
+
value = value.slice(1, -1);
|
|
395
|
+
}
|
|
396
|
+
args.push('--env', `${key}=${value}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return args;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function resolveContainerModeArgs(mode) {
|
|
403
|
+
const modeAliasMap = {
|
|
404
|
+
common: 'common',
|
|
405
|
+
'docker-in-docker': 'dind',
|
|
406
|
+
dind: 'dind',
|
|
407
|
+
d: 'dind',
|
|
408
|
+
'mount-docker-socket': 'sock',
|
|
409
|
+
sock: 'sock',
|
|
410
|
+
s: 'sock'
|
|
411
|
+
};
|
|
412
|
+
const normalized = modeAliasMap[String(mode || '').trim().toLowerCase()];
|
|
413
|
+
if (!normalized) {
|
|
414
|
+
throw new Error(`未知 containerMode: ${mode}`);
|
|
415
|
+
}
|
|
416
|
+
if (normalized === 'common') {
|
|
417
|
+
return { mode: 'common', args: [] };
|
|
418
|
+
}
|
|
419
|
+
if (normalized === 'dind') {
|
|
420
|
+
return { mode: 'dind', args: ['--privileged'] };
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
mode: 'sock',
|
|
424
|
+
args: [
|
|
425
|
+
'--privileged',
|
|
426
|
+
'--volume', '/var/run/docker.sock:/var/run/docker.sock',
|
|
427
|
+
'--env', 'DOCKER_HOST=unix:///var/run/docker.sock',
|
|
428
|
+
'--env', 'CONTAINER_HOST=unix:///var/run/docker.sock'
|
|
429
|
+
]
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function resolveYoloCommand(yolo) {
|
|
434
|
+
const key = String(yolo || '').trim().toLowerCase();
|
|
435
|
+
if (!key) {
|
|
436
|
+
return '';
|
|
437
|
+
}
|
|
438
|
+
const mapped = YOLO_COMMAND_MAP[key];
|
|
439
|
+
if (!mapped) {
|
|
440
|
+
throw new Error(`未知 yolo 值: ${yolo}`);
|
|
441
|
+
}
|
|
442
|
+
return mapped;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function buildDefaultCommand(shellPrefix, shell, shellSuffix) {
|
|
446
|
+
const parts = [];
|
|
447
|
+
if (shellPrefix && String(shellPrefix).trim()) {
|
|
448
|
+
parts.push(String(shellPrefix).trim());
|
|
449
|
+
}
|
|
450
|
+
if (shell && String(shell).trim()) {
|
|
451
|
+
parts.push(String(shell).trim());
|
|
452
|
+
}
|
|
453
|
+
if (shellSuffix && String(shellSuffix).trim()) {
|
|
454
|
+
parts.push(String(shellSuffix).trim());
|
|
455
|
+
}
|
|
456
|
+
return parts.join(' ').trim();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function validateWebConfigShape(configObject) {
|
|
460
|
+
const config = toPlainObject(configObject);
|
|
461
|
+
|
|
462
|
+
if (hasOwn(config, 'containerName') && String(config.containerName || '').trim()) {
|
|
463
|
+
validateContainerNameStrict(resolveNowTemplate(String(config.containerName), () => '0101-0000'));
|
|
464
|
+
}
|
|
465
|
+
if (hasOwn(config, 'imageVersion') && String(config.imageVersion || '').trim()) {
|
|
466
|
+
validateImageVersionStrict(String(config.imageVersion).trim());
|
|
467
|
+
}
|
|
468
|
+
if (hasOwn(config, 'env')) {
|
|
469
|
+
normalizeEnvMap(config.env, 'env');
|
|
470
|
+
}
|
|
471
|
+
if (hasOwn(config, 'envFile')) {
|
|
472
|
+
normalizeStringArray(config.envFile, 'envFile');
|
|
473
|
+
}
|
|
474
|
+
if (hasOwn(config, 'volumes')) {
|
|
475
|
+
normalizeStringArray(config.volumes, 'volumes');
|
|
476
|
+
}
|
|
477
|
+
if (hasOwn(config, 'runs')) {
|
|
478
|
+
const runs = config.runs;
|
|
479
|
+
if (runs !== undefined && (typeof runs !== 'object' || runs === null || Array.isArray(runs))) {
|
|
480
|
+
throw new Error('runs 必须是对象(map)');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function readWebConfigSnapshot(configPath) {
|
|
486
|
+
const resolvedPath = path.resolve(configPath || getDefaultWebConfigPath());
|
|
487
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
488
|
+
return {
|
|
489
|
+
path: resolvedPath,
|
|
490
|
+
raw: DEFAULT_WEB_CONFIG_TEMPLATE,
|
|
491
|
+
parsed: {},
|
|
492
|
+
parseError: null,
|
|
493
|
+
exists: false
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const raw = fs.readFileSync(resolvedPath, 'utf-8');
|
|
498
|
+
if (!String(raw || '').trim()) {
|
|
499
|
+
return {
|
|
500
|
+
path: resolvedPath,
|
|
501
|
+
raw,
|
|
502
|
+
parsed: {},
|
|
503
|
+
parseError: null,
|
|
504
|
+
exists: true
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
const parsed = JSON5.parse(raw);
|
|
510
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
511
|
+
throw new Error('配置根节点必须是对象(map)');
|
|
512
|
+
}
|
|
513
|
+
const config = toPlainObject(parsed);
|
|
514
|
+
validateWebConfigShape(config);
|
|
515
|
+
return {
|
|
516
|
+
path: resolvedPath,
|
|
517
|
+
raw,
|
|
518
|
+
parsed: config,
|
|
519
|
+
parseError: null,
|
|
520
|
+
exists: true
|
|
521
|
+
};
|
|
522
|
+
} catch (e) {
|
|
523
|
+
return {
|
|
524
|
+
path: resolvedPath,
|
|
525
|
+
raw,
|
|
526
|
+
parsed: {},
|
|
527
|
+
parseError: e && e.message ? e.message : '配置解析失败',
|
|
528
|
+
exists: true
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function parseAndValidateConfigRaw(raw) {
|
|
534
|
+
const parsed = JSON5.parse(String(raw || ''));
|
|
535
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
536
|
+
throw new Error('配置根节点必须是对象(map)');
|
|
537
|
+
}
|
|
538
|
+
const config = toPlainObject(parsed);
|
|
539
|
+
validateWebConfigShape(config);
|
|
540
|
+
return config;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function buildConfigDefaults(ctx, config) {
|
|
544
|
+
const parsed = toPlainObject(config);
|
|
545
|
+
const defaults = {
|
|
546
|
+
containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
|
|
547
|
+
hostPath: pickFirstString(parsed.hostPath, ctx.hostPath),
|
|
548
|
+
containerPath: pickFirstString(parsed.containerPath, ctx.containerPath),
|
|
549
|
+
imageName: pickFirstString(parsed.imageName, ctx.imageName),
|
|
550
|
+
imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
|
|
551
|
+
containerMode: hasOwn(parsed, 'containerMode') ? String(parsed.containerMode || '') : '',
|
|
552
|
+
shellPrefix: hasOwn(parsed, 'shellPrefix') ? String(parsed.shellPrefix || '') : '',
|
|
553
|
+
shell: hasOwn(parsed, 'shell') ? String(parsed.shell || '') : '',
|
|
554
|
+
shellSuffix: hasOwn(parsed, 'shellSuffix') ? String(parsed.shellSuffix || '') : '',
|
|
555
|
+
yolo: hasOwn(parsed, 'yolo') ? String(parsed.yolo || '') : '',
|
|
556
|
+
env: {},
|
|
557
|
+
envFile: [],
|
|
558
|
+
volumes: []
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
defaults.env = normalizeEnvMap(parsed.env, 'env');
|
|
563
|
+
} catch (e) {
|
|
564
|
+
defaults.env = {};
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
defaults.envFile = normalizeStringArray(parsed.envFile, 'envFile');
|
|
568
|
+
} catch (e) {
|
|
569
|
+
defaults.envFile = [];
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
defaults.volumes = normalizeStringArray(parsed.volumes, 'volumes');
|
|
573
|
+
} catch (e) {
|
|
574
|
+
defaults.volumes = [];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return defaults;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function buildStaticContainerRuntime(ctx, containerName) {
|
|
581
|
+
return {
|
|
582
|
+
containerName,
|
|
583
|
+
hostPath: ctx.hostPath,
|
|
584
|
+
containerPath: ctx.containerPath,
|
|
585
|
+
imageName: ctx.imageName,
|
|
586
|
+
imageVersion: ctx.imageVersion,
|
|
587
|
+
contModeArgs: Array.isArray(ctx.contModeArgs) ? ctx.contModeArgs.slice() : [],
|
|
588
|
+
containerEnvs: Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [],
|
|
589
|
+
containerVolumes: Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [],
|
|
590
|
+
defaultCommand: buildDefaultCommand(ctx.execCommandPrefix, ctx.execCommand, ctx.execCommandSuffix) || '/bin/bash'
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function buildCreateRuntime(ctx, state, payload) {
|
|
595
|
+
const body = toPlainObject(payload);
|
|
596
|
+
const requestOptions = toPlainObject(body.createOptions);
|
|
597
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
598
|
+
const config = snapshot.parseError ? {} : snapshot.parsed;
|
|
599
|
+
|
|
600
|
+
const hasRequestEnv = hasOwn(requestOptions, 'env');
|
|
601
|
+
const hasRequestEnvFile = hasOwn(requestOptions, 'envFile');
|
|
602
|
+
const hasRequestVolumes = hasOwn(requestOptions, 'volumes');
|
|
603
|
+
const hasConfigEnv = hasOwn(config, 'env');
|
|
604
|
+
const hasConfigEnvFile = hasOwn(config, 'envFile');
|
|
605
|
+
const hasConfigVolumes = hasOwn(config, 'volumes');
|
|
606
|
+
|
|
607
|
+
const requestName = pickFirstString(requestOptions.containerName, body.name);
|
|
608
|
+
let containerName = pickFirstString(requestName, config.containerName);
|
|
609
|
+
if (!containerName) {
|
|
610
|
+
containerName = `my-${ctx.formatDate()}`;
|
|
611
|
+
}
|
|
612
|
+
containerName = resolveNowTemplate(containerName, ctx.formatDate);
|
|
613
|
+
validateContainerNameStrict(containerName);
|
|
614
|
+
|
|
615
|
+
const hostPath = pickFirstString(requestOptions.hostPath, config.hostPath, ctx.hostPath);
|
|
616
|
+
validateWebHostPath(hostPath);
|
|
617
|
+
|
|
618
|
+
const containerPath = pickFirstString(requestOptions.containerPath, config.containerPath, ctx.containerPath, hostPath) || hostPath;
|
|
619
|
+
const imageName = pickFirstString(requestOptions.imageName, config.imageName, ctx.imageName);
|
|
620
|
+
const imageVersion = pickFirstString(requestOptions.imageVersion, config.imageVersion, ctx.imageVersion);
|
|
621
|
+
|
|
622
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/:-]*$/.test(imageName)) {
|
|
623
|
+
throw new Error(`imageName 非法: ${imageName}`);
|
|
624
|
+
}
|
|
625
|
+
validateImageVersionStrict(imageVersion);
|
|
626
|
+
|
|
627
|
+
let contModeArgs = Array.isArray(ctx.contModeArgs) ? ctx.contModeArgs.slice() : [];
|
|
628
|
+
let containerMode = '';
|
|
629
|
+
const modeValue = pickFirstString(requestOptions.containerMode, config.containerMode);
|
|
630
|
+
if (modeValue) {
|
|
631
|
+
const mode = resolveContainerModeArgs(modeValue);
|
|
632
|
+
containerMode = mode.mode;
|
|
633
|
+
contModeArgs = mode.args;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const shellPrefix = hasOwn(requestOptions, 'shellPrefix')
|
|
637
|
+
? String(requestOptions.shellPrefix || '')
|
|
638
|
+
: (hasOwn(config, 'shellPrefix') ? String(config.shellPrefix || '') : String(ctx.execCommandPrefix || ''));
|
|
639
|
+
let shell = hasOwn(requestOptions, 'shell')
|
|
640
|
+
? String(requestOptions.shell || '')
|
|
641
|
+
: (hasOwn(config, 'shell') ? String(config.shell || '') : String(ctx.execCommand || ''));
|
|
642
|
+
const shellSuffix = hasOwn(requestOptions, 'shellSuffix')
|
|
643
|
+
? String(requestOptions.shellSuffix || '')
|
|
644
|
+
: (hasOwn(config, 'shellSuffix') ? String(config.shellSuffix || '') : String(ctx.execCommandSuffix || ''));
|
|
645
|
+
const yolo = hasOwn(requestOptions, 'yolo')
|
|
646
|
+
? String(requestOptions.yolo || '')
|
|
647
|
+
: (hasOwn(config, 'yolo') ? String(config.yolo || '') : '');
|
|
648
|
+
const yoloCommand = resolveYoloCommand(yolo);
|
|
649
|
+
if (yoloCommand) {
|
|
650
|
+
shell = yoloCommand;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let containerEnvs = Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [];
|
|
654
|
+
if (hasRequestEnv || hasRequestEnvFile || hasConfigEnv || hasConfigEnvFile) {
|
|
655
|
+
const configEnv = normalizeEnvMap(config.env, 'config.env');
|
|
656
|
+
const requestEnv = hasRequestEnv ? normalizeEnvMap(requestOptions.env, 'createOptions.env') : {};
|
|
657
|
+
const mergedEnv = { ...configEnv, ...requestEnv };
|
|
658
|
+
const envArgs = [];
|
|
659
|
+
Object.entries(mergedEnv).forEach(([key, value]) => {
|
|
660
|
+
const parsed = parseEnvEntry(`${key}=${value}`);
|
|
661
|
+
envArgs.push('--env', `${parsed.key}=${parsed.value}`);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const envFileList = hasRequestEnvFile
|
|
665
|
+
? normalizeStringArray(requestOptions.envFile, 'createOptions.envFile')
|
|
666
|
+
: normalizeStringArray(config.envFile, 'config.envFile');
|
|
667
|
+
const envFileArgs = [];
|
|
668
|
+
envFileList.forEach(filePath => {
|
|
669
|
+
envFileArgs.push(...parseEnvFileToArgs(filePath));
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
containerEnvs = [...envArgs, ...envFileArgs];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
let containerVolumes = Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [];
|
|
676
|
+
if (hasRequestVolumes || hasConfigVolumes) {
|
|
677
|
+
const volumeList = hasRequestVolumes
|
|
678
|
+
? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes')
|
|
679
|
+
: normalizeStringArray(config.volumes, 'config.volumes');
|
|
680
|
+
containerVolumes = [];
|
|
681
|
+
volumeList.forEach(volume => {
|
|
682
|
+
containerVolumes.push('--volume', volume);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
containerName,
|
|
688
|
+
hostPath,
|
|
689
|
+
containerPath,
|
|
690
|
+
imageName,
|
|
691
|
+
imageVersion,
|
|
692
|
+
contModeArgs,
|
|
693
|
+
containerEnvs,
|
|
694
|
+
containerVolumes,
|
|
695
|
+
defaultCommand: buildDefaultCommand(shellPrefix, shell, shellSuffix) || '/bin/bash',
|
|
696
|
+
applied: {
|
|
697
|
+
containerName,
|
|
698
|
+
hostPath,
|
|
699
|
+
containerPath,
|
|
700
|
+
imageName,
|
|
701
|
+
imageVersion,
|
|
702
|
+
containerMode,
|
|
703
|
+
shellPrefix: shellPrefix || '',
|
|
704
|
+
shell: shell || '',
|
|
705
|
+
shellSuffix: shellSuffix || '',
|
|
706
|
+
yolo: yolo || '',
|
|
707
|
+
envCount: Math.floor(containerEnvs.length / 2),
|
|
708
|
+
volumeCount: Math.floor(containerVolumes.length / 2)
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
220
713
|
function listWebManyoyoContainers(ctx) {
|
|
221
714
|
const output = ctx.dockerExecArgs(
|
|
222
715
|
['ps', '-a', '--filter', 'label=manyoyo.default_cmd', '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}'],
|
|
@@ -243,23 +736,27 @@ function listWebManyoyoContainers(ctx) {
|
|
|
243
736
|
return map;
|
|
244
737
|
}
|
|
245
738
|
|
|
246
|
-
async function ensureWebContainer(ctx, state,
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
739
|
+
async function ensureWebContainer(ctx, state, containerInput) {
|
|
740
|
+
const runtime = typeof containerInput === 'string'
|
|
741
|
+
? buildStaticContainerRuntime(ctx, containerInput)
|
|
742
|
+
: containerInput;
|
|
743
|
+
|
|
744
|
+
if (!runtime || !runtime.containerName) {
|
|
745
|
+
throw new Error('containerName 不能为空');
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (!ctx.containerExists(runtime.containerName)) {
|
|
749
|
+
const args = buildContainerRunArgs({
|
|
750
|
+
containerName: runtime.containerName,
|
|
751
|
+
hostPath: runtime.hostPath,
|
|
752
|
+
containerPath: runtime.containerPath,
|
|
753
|
+
imageName: runtime.imageName,
|
|
754
|
+
imageVersion: runtime.imageVersion,
|
|
755
|
+
contModeArgs: runtime.contModeArgs,
|
|
756
|
+
containerEnvs: runtime.containerEnvs,
|
|
757
|
+
containerVolumes: runtime.containerVolumes,
|
|
758
|
+
defaultCommand: runtime.defaultCommand
|
|
759
|
+
});
|
|
263
760
|
|
|
264
761
|
try {
|
|
265
762
|
ctx.dockerExecArgs(args, { stdio: 'pipe' });
|
|
@@ -268,33 +765,58 @@ async function ensureWebContainer(ctx, state, containerName) {
|
|
|
268
765
|
throw e;
|
|
269
766
|
}
|
|
270
767
|
|
|
271
|
-
await ctx.waitForContainerReady(containerName);
|
|
272
|
-
appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已创建并启动。`);
|
|
768
|
+
await ctx.waitForContainerReady(runtime.containerName);
|
|
769
|
+
appendWebSessionMessage(state.webHistoryDir, runtime.containerName, 'system', `容器 ${runtime.containerName} 已创建并启动。`);
|
|
273
770
|
return;
|
|
274
771
|
}
|
|
275
772
|
|
|
276
|
-
const status = ctx.getContainerStatus(containerName);
|
|
773
|
+
const status = ctx.getContainerStatus(runtime.containerName);
|
|
277
774
|
if (status !== 'running') {
|
|
278
|
-
ctx.dockerExecArgs(['start', containerName], { stdio: 'pipe' });
|
|
279
|
-
appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已启动。`);
|
|
775
|
+
ctx.dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
|
|
776
|
+
appendWebSessionMessage(state.webHistoryDir, runtime.containerName, 'system', `容器 ${runtime.containerName} 已启动。`);
|
|
280
777
|
}
|
|
281
778
|
}
|
|
282
779
|
|
|
283
|
-
function execCommandInWebContainer(ctx, containerName, command) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
780
|
+
async function execCommandInWebContainer(ctx, containerName, command) {
|
|
781
|
+
return await new Promise((resolve, reject) => {
|
|
782
|
+
const process = spawn(
|
|
783
|
+
ctx.dockerCmd,
|
|
784
|
+
['exec', containerName, '/bin/bash', '-lc', command],
|
|
785
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
786
|
+
);
|
|
288
787
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
788
|
+
const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
|
|
789
|
+
let rawOutput = '';
|
|
790
|
+
let outputTruncated = false;
|
|
791
|
+
|
|
792
|
+
function appendChunk(chunk) {
|
|
793
|
+
if (!chunk) return;
|
|
794
|
+
const text = chunk.toString('utf-8');
|
|
795
|
+
if (!text) return;
|
|
796
|
+
if (rawOutput.length >= MAX_RAW_OUTPUT_CHARS) {
|
|
797
|
+
outputTruncated = true;
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const remain = MAX_RAW_OUTPUT_CHARS - rawOutput.length;
|
|
801
|
+
if (text.length > remain) {
|
|
802
|
+
rawOutput += text.slice(0, remain);
|
|
803
|
+
outputTruncated = true;
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
rawOutput += text;
|
|
807
|
+
}
|
|
292
808
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const output = clipText(stripAnsi(rawOutput).trim() || '(无输出)');
|
|
809
|
+
process.stdout.on('data', appendChunk);
|
|
810
|
+
process.stderr.on('data', appendChunk);
|
|
296
811
|
|
|
297
|
-
|
|
812
|
+
process.on('error', reject);
|
|
813
|
+
process.on('close', code => {
|
|
814
|
+
const exitCode = typeof code === 'number' ? code : 1;
|
|
815
|
+
const clippedRaw = outputTruncated ? `${rawOutput}\n...[raw-truncated]` : rawOutput;
|
|
816
|
+
const output = clipText(stripAnsi(clippedRaw).trim() || '(无输出)');
|
|
817
|
+
resolve({ exitCode, output });
|
|
818
|
+
});
|
|
819
|
+
});
|
|
298
820
|
}
|
|
299
821
|
|
|
300
822
|
function readRequestBody(req) {
|
|
@@ -350,6 +872,15 @@ function decodeSessionName(encoded) {
|
|
|
350
872
|
}
|
|
351
873
|
}
|
|
352
874
|
|
|
875
|
+
function getValidSessionName(ctx, res, encodedName) {
|
|
876
|
+
const containerName = decodeSessionName(encodedName);
|
|
877
|
+
if (!ctx.isValidContainerName(containerName)) {
|
|
878
|
+
sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
return containerName;
|
|
882
|
+
}
|
|
883
|
+
|
|
353
884
|
function buildSessionSummary(ctx, state, containerMap, name) {
|
|
354
885
|
const history = loadWebSessionHistory(state.webHistoryDir, name);
|
|
355
886
|
const latestMessage = history.messages.length ? history.messages[history.messages.length - 1] : null;
|
|
@@ -686,111 +1217,172 @@ function sendWebUnauthorized(res, pathname) {
|
|
|
686
1217
|
}
|
|
687
1218
|
|
|
688
1219
|
async function handleWebApi(req, res, pathname, ctx, state) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
1220
|
+
const routes = [
|
|
1221
|
+
{
|
|
1222
|
+
method: 'GET',
|
|
1223
|
+
match: currentPath => currentPath === '/api/config' ? [] : null,
|
|
1224
|
+
handler: async () => {
|
|
1225
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
1226
|
+
const defaults = buildConfigDefaults(ctx, snapshot.parseError ? {} : snapshot.parsed);
|
|
1227
|
+
sendJson(res, 200, {
|
|
1228
|
+
path: snapshot.path,
|
|
1229
|
+
raw: snapshot.raw,
|
|
1230
|
+
parsed: snapshot.parseError ? null : snapshot.parsed,
|
|
1231
|
+
parseError: snapshot.parseError,
|
|
1232
|
+
defaults
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
},
|
|
1236
|
+
{
|
|
1237
|
+
method: 'PUT',
|
|
1238
|
+
match: currentPath => currentPath === '/api/config' ? [] : null,
|
|
1239
|
+
handler: async () => {
|
|
1240
|
+
const payload = await readJsonBody(req);
|
|
1241
|
+
const raw = typeof payload.raw === 'string' ? payload.raw : '';
|
|
1242
|
+
if (!raw.trim()) {
|
|
1243
|
+
sendJson(res, 400, { error: '配置内容不能为空' });
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
703
1246
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1247
|
+
let parsed = null;
|
|
1248
|
+
try {
|
|
1249
|
+
parsed = parseAndValidateConfigRaw(raw);
|
|
1250
|
+
} catch (e) {
|
|
1251
|
+
sendJson(res, 400, { error: '配置格式错误', detail: e.message || '解析失败' });
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
707
1254
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if (!containerName) {
|
|
712
|
-
containerName = `my-${ctx.formatDate()}`;
|
|
713
|
-
}
|
|
714
|
-
if (!ctx.isValidContainerName(containerName)) {
|
|
715
|
-
sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
|
|
716
|
-
return true;
|
|
717
|
-
}
|
|
1255
|
+
const savePath = path.resolve(state.webConfigPath);
|
|
1256
|
+
fs.mkdirSync(path.dirname(savePath), { recursive: true });
|
|
1257
|
+
fs.writeFileSync(savePath, raw, 'utf-8');
|
|
718
1258
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
1259
|
+
sendJson(res, 200, {
|
|
1260
|
+
saved: true,
|
|
1261
|
+
path: savePath,
|
|
1262
|
+
defaults: buildConfigDefaults(ctx, parsed)
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
},
|
|
1266
|
+
{
|
|
1267
|
+
method: 'GET',
|
|
1268
|
+
match: currentPath => currentPath === '/api/sessions' ? [] : null,
|
|
1269
|
+
handler: async () => {
|
|
1270
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
1271
|
+
const names = new Set([
|
|
1272
|
+
...Object.keys(containerMap),
|
|
1273
|
+
...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
|
|
1274
|
+
]);
|
|
1275
|
+
|
|
1276
|
+
const sessions = Array.from(names)
|
|
1277
|
+
.map(name => buildSessionSummary(ctx, state, containerMap, name))
|
|
1278
|
+
.sort((a, b) => {
|
|
1279
|
+
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
1280
|
+
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
1281
|
+
return timeB - timeA;
|
|
1282
|
+
});
|
|
723
1283
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1284
|
+
sendJson(res, 200, { sessions });
|
|
1285
|
+
}
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
method: 'POST',
|
|
1289
|
+
match: currentPath => currentPath === '/api/sessions' ? [] : null,
|
|
1290
|
+
handler: async () => {
|
|
1291
|
+
const payload = await readJsonBody(req);
|
|
1292
|
+
let runtime = null;
|
|
1293
|
+
try {
|
|
1294
|
+
runtime = buildCreateRuntime(ctx, state, payload);
|
|
1295
|
+
} catch (e) {
|
|
1296
|
+
sendJson(res, 400, { error: e.message || '创建参数错误' });
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
731
1299
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1300
|
+
await ensureWebContainer(ctx, state, runtime);
|
|
1301
|
+
sendJson(res, 200, { name: runtime.containerName, applied: runtime.applied });
|
|
1302
|
+
}
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
method: 'GET',
|
|
1306
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages$/),
|
|
1307
|
+
handler: async match => {
|
|
1308
|
+
const containerName = getValidSessionName(ctx, res, match[1]);
|
|
1309
|
+
if (!containerName) {
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
const history = loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
1313
|
+
sendJson(res, 200, { name: containerName, messages: history.messages });
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
method: 'POST',
|
|
1318
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/run$/),
|
|
1319
|
+
handler: async match => {
|
|
1320
|
+
const containerName = getValidSessionName(ctx, res, match[1]);
|
|
1321
|
+
if (!containerName) {
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
736
1324
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
1325
|
+
const payload = await readJsonBody(req);
|
|
1326
|
+
const command = (payload.command || '').trim();
|
|
1327
|
+
if (!command) {
|
|
1328
|
+
sendJson(res, 400, { error: 'command 不能为空' });
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
744
1331
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1332
|
+
await ensureWebContainer(ctx, state, containerName);
|
|
1333
|
+
appendWebSessionMessage(state.webHistoryDir, containerName, 'user', command);
|
|
1334
|
+
const result = await execCommandInWebContainer(ctx, containerName, command);
|
|
1335
|
+
appendWebSessionMessage(
|
|
1336
|
+
state.webHistoryDir,
|
|
1337
|
+
containerName,
|
|
1338
|
+
'assistant',
|
|
1339
|
+
result.output,
|
|
1340
|
+
{ exitCode: result.exitCode }
|
|
1341
|
+
);
|
|
1342
|
+
sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
|
|
1343
|
+
}
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
method: 'POST',
|
|
1347
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/remove$/),
|
|
1348
|
+
handler: async match => {
|
|
1349
|
+
const containerName = getValidSessionName(ctx, res, match[1]);
|
|
1350
|
+
if (!containerName) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
751
1353
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
state.webHistoryDir,
|
|
757
|
-
containerName,
|
|
758
|
-
'assistant',
|
|
759
|
-
`${result.output}\n\n[exit ${result.exitCode}]`,
|
|
760
|
-
{ exitCode: result.exitCode }
|
|
761
|
-
);
|
|
762
|
-
sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
|
|
763
|
-
return true;
|
|
764
|
-
}
|
|
1354
|
+
if (ctx.containerExists(containerName)) {
|
|
1355
|
+
ctx.removeContainer(containerName);
|
|
1356
|
+
appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已删除。`);
|
|
1357
|
+
}
|
|
765
1358
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1359
|
+
sendJson(res, 200, { removed: true, name: containerName });
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
method: 'POST',
|
|
1364
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/remove-with-history$/),
|
|
1365
|
+
handler: async match => {
|
|
1366
|
+
const containerName = getValidSessionName(ctx, res, match[1]);
|
|
1367
|
+
if (!containerName) {
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
773
1370
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1371
|
+
removeWebSessionHistory(state.webHistoryDir, containerName);
|
|
1372
|
+
sendJson(res, 200, { removedHistory: true, name: containerName });
|
|
1373
|
+
}
|
|
777
1374
|
}
|
|
1375
|
+
];
|
|
778
1376
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const removeHistoryMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/remove-with-history$/);
|
|
784
|
-
if (req.method === 'POST' && removeHistoryMatch) {
|
|
785
|
-
const containerName = decodeSessionName(removeHistoryMatch[1]);
|
|
786
|
-
if (!ctx.isValidContainerName(containerName)) {
|
|
787
|
-
sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
|
|
788
|
-
return true;
|
|
1377
|
+
for (const route of routes) {
|
|
1378
|
+
if (route.method !== req.method) {
|
|
1379
|
+
continue;
|
|
789
1380
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1381
|
+
const matched = route.match(pathname);
|
|
1382
|
+
if (!matched) {
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
await route.handler(matched);
|
|
794
1386
|
return true;
|
|
795
1387
|
}
|
|
796
1388
|
|
|
@@ -838,6 +1430,7 @@ async function startWebServer(options) {
|
|
|
838
1430
|
|
|
839
1431
|
const state = {
|
|
840
1432
|
webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
|
|
1433
|
+
webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
|
|
841
1434
|
authSessions: new Map(),
|
|
842
1435
|
terminalSessions: new Map()
|
|
843
1436
|
};
|
|
@@ -980,12 +1573,18 @@ async function startWebServer(options) {
|
|
|
980
1573
|
});
|
|
981
1574
|
});
|
|
982
1575
|
|
|
1576
|
+
let listenPort = ctx.serverPort;
|
|
1577
|
+
|
|
983
1578
|
await new Promise((resolve, reject) => {
|
|
984
1579
|
server.once('error', reject);
|
|
985
1580
|
server.listen(ctx.serverPort, ctx.serverHost, () => {
|
|
1581
|
+
const address = server.address();
|
|
1582
|
+
if (address && typeof address === 'object' && typeof address.port === 'number') {
|
|
1583
|
+
listenPort = address.port;
|
|
1584
|
+
}
|
|
986
1585
|
const { GREEN, CYAN, YELLOW, NC } = ctx.colors;
|
|
987
1586
|
const listenHost = formatUrlHost(ctx.serverHost);
|
|
988
|
-
console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${
|
|
1587
|
+
console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${listenPort}${NC}`);
|
|
989
1588
|
console.log(`${CYAN}提示: 左侧是 manyoyo 容器会话列表,右侧支持命令模式与交互式终端模式。${NC}`);
|
|
990
1589
|
if (ctx.serverHost === '0.0.0.0') {
|
|
991
1590
|
console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 IP 访问。${NC}`);
|
|
@@ -999,6 +1598,36 @@ async function startWebServer(options) {
|
|
|
999
1598
|
resolve();
|
|
1000
1599
|
});
|
|
1001
1600
|
});
|
|
1601
|
+
|
|
1602
|
+
return {
|
|
1603
|
+
server,
|
|
1604
|
+
wsServer,
|
|
1605
|
+
host: ctx.serverHost,
|
|
1606
|
+
port: listenPort,
|
|
1607
|
+
close: () => new Promise(resolve => {
|
|
1608
|
+
for (const session of state.terminalSessions.values()) {
|
|
1609
|
+
const ptyProcess = session && session.ptyProcess;
|
|
1610
|
+
if (ptyProcess && !ptyProcess.killed) {
|
|
1611
|
+
try { ptyProcess.kill('SIGTERM'); } catch (e) {}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
state.terminalSessions.clear();
|
|
1615
|
+
|
|
1616
|
+
const closeHttp = () => {
|
|
1617
|
+
if (!server.listening) {
|
|
1618
|
+
resolve();
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
server.close(() => resolve());
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
try {
|
|
1625
|
+
wsServer.close(() => closeHttp());
|
|
1626
|
+
} catch (e) {
|
|
1627
|
+
closeHttp();
|
|
1628
|
+
}
|
|
1629
|
+
})
|
|
1630
|
+
};
|
|
1002
1631
|
}
|
|
1003
1632
|
|
|
1004
1633
|
module.exports = {
|