cloudflared-manager 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +63 -0
- package/README.md +217 -0
- package/cloudflared_manager.mjs +2080 -0
- package/cloudflared_manager.sh +2727 -0
- package/package.json +26 -0
|
@@ -0,0 +1,2080 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { emitKeypressEvents } from "node:readline";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const SHELL_SCRIPT = path.join(SCRIPT_DIR, "cloudflared_manager.sh");
|
|
13
|
+
const INTERACTIVE_FLAGS = new Set(["interactive", "--interactive"]);
|
|
14
|
+
const BACK_KEYWORDS = new Set(["back", "返回", ".."]);
|
|
15
|
+
|
|
16
|
+
// 统一规范化交互输入,便于判断返回命令等关键字。
|
|
17
|
+
function normalizeInput(value) {
|
|
18
|
+
return (value ?? "").trim().toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 判断单次输入是否明确表示“返回”。
|
|
22
|
+
function isBackKeyword(value) {
|
|
23
|
+
return BACK_KEYWORDS.has(normalizeInput(value));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 判断当前累计文本是否已经匹配到返回关键字。
|
|
27
|
+
function endsWithBackKeyword(value) {
|
|
28
|
+
const normalized = normalizeInput(value);
|
|
29
|
+
return Array.from(BACK_KEYWORDS).some((keyword) => normalized.endsWith(keyword));
|
|
30
|
+
}
|
|
31
|
+
const COLORS = {
|
|
32
|
+
reset: "\x1b[0m",
|
|
33
|
+
bold: "\x1b[1m",
|
|
34
|
+
dim: "\x1b[2m",
|
|
35
|
+
cyan: "\x1b[36m",
|
|
36
|
+
green: "\x1b[32m",
|
|
37
|
+
yellow: "\x1b[33m",
|
|
38
|
+
red: "\x1b[31m",
|
|
39
|
+
blue: "\x1b[34m",
|
|
40
|
+
gray: "\x1b[90m",
|
|
41
|
+
white: "\x1b[37m",
|
|
42
|
+
brightWhite: "\x1b[97m",
|
|
43
|
+
selectedBg: "\x1b[104m",
|
|
44
|
+
inverse: "\x1b[7m",
|
|
45
|
+
};
|
|
46
|
+
const MAIN_MENU_KEY = "main";
|
|
47
|
+
const MENUS = {
|
|
48
|
+
main: {
|
|
49
|
+
title: "主菜单",
|
|
50
|
+
subtitle: "先选操作分类,再进入二级菜单。",
|
|
51
|
+
items: [
|
|
52
|
+
{
|
|
53
|
+
value: "1",
|
|
54
|
+
label: "环境与登录",
|
|
55
|
+
hint: "检查环境、初始化目录、登录和证书管理",
|
|
56
|
+
kind: "primary",
|
|
57
|
+
next: "env",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
value: "2",
|
|
61
|
+
label: "新建与接管",
|
|
62
|
+
hint: "创建新 Tunnel,或接管已有 Tunnel",
|
|
63
|
+
kind: "primary",
|
|
64
|
+
next: "setup",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
value: "3",
|
|
68
|
+
label: "现有应用管理",
|
|
69
|
+
hint: "按应用执行查看、运行、Ingress 和删除操作",
|
|
70
|
+
kind: "secondary",
|
|
71
|
+
next: "app_picker",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
value: "4",
|
|
75
|
+
label: "远端与高级",
|
|
76
|
+
hint: "查看远端 Tunnel,或执行原始命令",
|
|
77
|
+
kind: "assist",
|
|
78
|
+
next: "advanced",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
value: "0",
|
|
82
|
+
label: "退出",
|
|
83
|
+
hint: "离开交互模式",
|
|
84
|
+
kind: "danger",
|
|
85
|
+
exit: true,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
env: {
|
|
90
|
+
title: "环境与登录",
|
|
91
|
+
subtitle: "路径、初始化、登录和证书操作。",
|
|
92
|
+
items: [
|
|
93
|
+
{ value: "1", label: "环境检查", hint: "查看路径、配置和 cloudflared 状态 (doctor)", kind: "primary", action: "1", group: "环境" },
|
|
94
|
+
{ value: "2", label: "初始化目录", hint: "创建管理目录,并可选执行登录 (init)", kind: "primary", action: "17", group: "环境" },
|
|
95
|
+
{ value: "3", label: "保存当前路径", hint: "保存当前 profile 解析结果 (use)", kind: "secondary", action: "18", group: "环境" },
|
|
96
|
+
{ value: "4", label: "登录 Cloudflare", hint: "执行 tunnel login 并同步证书 (login)", kind: "primary", action: "19", group: "登录与证书" },
|
|
97
|
+
{ value: "5", label: "导入证书", hint: "把已有 cert.pem 导入当前路径 (import-cert)", kind: "assist", action: "20", group: "登录与证书" },
|
|
98
|
+
{ value: "0", label: "返回主菜单", hint: "回到一级菜单", kind: "back", back: true },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
setup: {
|
|
102
|
+
title: "新建与接管",
|
|
103
|
+
subtitle: "创建新的 tunnel,或接管已有 tunnel。",
|
|
104
|
+
items: [
|
|
105
|
+
{ value: "1", label: "创建新 tunnel", hint: "创建新 Tunnel 并写入本地受管配置 (add)", kind: "primary", action: "5", group: "创建与接管" },
|
|
106
|
+
{ value: "2", label: "接管已有 tunnel", hint: "把远端 Tunnel 纳入本地受管目录 (adopt)", kind: "primary", action: "6", group: "创建与接管" },
|
|
107
|
+
{ value: "0", label: "返回主菜单", hint: "回到一级菜单", kind: "back", back: true },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
advanced: {
|
|
111
|
+
title: "远端与高级",
|
|
112
|
+
subtitle: "远端 tunnel 查询与自定义命令。",
|
|
113
|
+
items: [
|
|
114
|
+
{ value: "1", label: "查看远端 tunnels", hint: "列出当前账号下的远端 Tunnel (tunnels)", kind: "secondary", action: "3", group: "远端信息" },
|
|
115
|
+
{ value: "2", label: "执行自定义命令", hint: "原样透传到底层 shell 脚本", kind: "assist", action: "21", group: "高级" },
|
|
116
|
+
{ value: "0", label: "返回主菜单", hint: "回到一级菜单", kind: "back", back: true },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// 用于表示当前交互流程应立即返回主菜单。
|
|
122
|
+
class BackToMenuError extends Error {
|
|
123
|
+
constructor() {
|
|
124
|
+
super("返回主菜单");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 输出错误并退出。
|
|
129
|
+
function fail(message, code = 1) {
|
|
130
|
+
console.error(`错误: ${message}`);
|
|
131
|
+
process.exit(code);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 判断当前终端是否适合启用 ANSI 颜色。
|
|
135
|
+
function supportsColor() {
|
|
136
|
+
return process.stdout.isTTY && !("NO_COLOR" in process.env);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 给文本应用 ANSI 颜色。
|
|
140
|
+
function colorize(text, ...codes) {
|
|
141
|
+
if (!supportsColor()) {
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
return `${codes.join("")}${text}${COLORS.reset}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 获取当前终端列宽,无法判断时回退到 80。
|
|
148
|
+
function terminalColumns() {
|
|
149
|
+
return process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 估算终端显示宽度,中文等宽字符按 2 处理。
|
|
153
|
+
function displayWidth(text) {
|
|
154
|
+
return Array.from(text).reduce((width, char) => width + (char.charCodeAt(0) > 0xff ? 2 : 1), 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 按显示宽度裁剪文本,避免单行信息过长。
|
|
158
|
+
function truncateDisplayText(text, maxWidth) {
|
|
159
|
+
if (displayWidth(text) <= maxWidth) {
|
|
160
|
+
return text;
|
|
161
|
+
}
|
|
162
|
+
const ellipsis = "…";
|
|
163
|
+
const limit = Math.max(1, maxWidth - displayWidth(ellipsis));
|
|
164
|
+
let current = "";
|
|
165
|
+
let width = 0;
|
|
166
|
+
for (const char of Array.from(text)) {
|
|
167
|
+
const nextWidth = width + (char.charCodeAt(0) > 0xff ? 2 : 1);
|
|
168
|
+
if (nextWidth > limit) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
current += char;
|
|
172
|
+
width = nextWidth;
|
|
173
|
+
}
|
|
174
|
+
return `${current}${ellipsis}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 按显示宽度把文本居中到指定宽度。
|
|
178
|
+
function centerText(text, width) {
|
|
179
|
+
const visibleWidth = displayWidth(text);
|
|
180
|
+
const padding = Math.max(0, width - visibleWidth);
|
|
181
|
+
const left = Math.floor(padding / 2);
|
|
182
|
+
const right = padding - left;
|
|
183
|
+
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 渲染交互菜单顶部品牌标题框。
|
|
187
|
+
function renderBanner(title, subtitle) {
|
|
188
|
+
const maxInnerWidth = Math.max(28, terminalColumns() - 2);
|
|
189
|
+
const desiredWidth = Math.max(34, displayWidth(title) + 10, displayWidth(subtitle) + 10);
|
|
190
|
+
const innerWidth = Math.min(desiredWidth, maxInnerWidth);
|
|
191
|
+
const fittedTitle = truncateDisplayText(title, innerWidth);
|
|
192
|
+
const fittedSubtitle = truncateDisplayText(subtitle, innerWidth);
|
|
193
|
+
const top = `╭${"─".repeat(innerWidth)}╮`;
|
|
194
|
+
const bottom = `╰${"─".repeat(innerWidth)}╯`;
|
|
195
|
+
|
|
196
|
+
return [
|
|
197
|
+
colorize(top, COLORS.bold, COLORS.cyan),
|
|
198
|
+
`${colorize("│", COLORS.bold, COLORS.cyan)}${colorize(centerText(fittedTitle, innerWidth), COLORS.bold, COLORS.white)}${colorize("│", COLORS.bold, COLORS.cyan)}`,
|
|
199
|
+
`${colorize("│", COLORS.bold, COLORS.cyan)}${colorize(centerText(fittedSubtitle, innerWidth), COLORS.white)}${colorize("│", COLORS.bold, COLORS.cyan)}`,
|
|
200
|
+
colorize(bottom, COLORS.bold, COLORS.cyan),
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 把菜单编号格式化为两位数,保证纵向对齐。
|
|
205
|
+
function formatMenuValue(value) {
|
|
206
|
+
return /^[0-9]+$/.test(value) ? value.padStart(2, "0") : value;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 把菜单输入编号规范化,允许 01/00 这类带前导零的写法。
|
|
210
|
+
function normalizeMenuChoice(value) {
|
|
211
|
+
if (!/^[0-9]+$/.test(value)) {
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
return String(Number.parseInt(value, 10));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 把布尔值渲染为中文状态。
|
|
218
|
+
function formatEnabled(value) {
|
|
219
|
+
return value ? "是" : "否";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 根据当前菜单生成顶部面包屑路径。
|
|
223
|
+
function buildBreadcrumb(menuKey, appName = "") {
|
|
224
|
+
switch (menuKey) {
|
|
225
|
+
case MAIN_MENU_KEY:
|
|
226
|
+
return "";
|
|
227
|
+
case "env":
|
|
228
|
+
return "主菜单";
|
|
229
|
+
case "setup":
|
|
230
|
+
return "主菜单";
|
|
231
|
+
case "advanced":
|
|
232
|
+
return "主菜单";
|
|
233
|
+
case "app_picker":
|
|
234
|
+
return "主菜单 / 现有应用管理";
|
|
235
|
+
case "app_detail":
|
|
236
|
+
return "主菜单 / 现有应用管理";
|
|
237
|
+
default:
|
|
238
|
+
return "主菜单";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 根据语义类型决定菜单项颜色。
|
|
243
|
+
function itemColors(kind) {
|
|
244
|
+
switch (kind) {
|
|
245
|
+
case "primary":
|
|
246
|
+
return [COLORS.bold, COLORS.cyan];
|
|
247
|
+
case "secondary":
|
|
248
|
+
return [COLORS.bold, COLORS.green];
|
|
249
|
+
case "assist":
|
|
250
|
+
return [COLORS.bold, COLORS.yellow];
|
|
251
|
+
case "danger":
|
|
252
|
+
return [COLORS.bold, COLORS.red];
|
|
253
|
+
case "back":
|
|
254
|
+
return [COLORS.bold, COLORS.blue];
|
|
255
|
+
default:
|
|
256
|
+
return [COLORS.bold, COLORS.white];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 执行底层 shell 脚本,可选捕获输出。
|
|
261
|
+
function runShell(baseArgs, commandArgs, options = {}) {
|
|
262
|
+
const args = [SHELL_SCRIPT, ...baseArgs, ...commandArgs];
|
|
263
|
+
const result = spawnSync("bash", args, {
|
|
264
|
+
stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
265
|
+
encoding: "utf8",
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (options.capture) {
|
|
269
|
+
return {
|
|
270
|
+
code: result.status ?? 1,
|
|
271
|
+
stdout: result.stdout ?? "",
|
|
272
|
+
stderr: result.stderr ?? "",
|
|
273
|
+
error: result.error,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (result.error) {
|
|
278
|
+
fail(result.error.message);
|
|
279
|
+
}
|
|
280
|
+
if (options.exitOnComplete === true) {
|
|
281
|
+
process.exit(result.status ?? 1);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
code: result.status ?? 1,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 读取一行输入,可选提供默认值。
|
|
289
|
+
async function ask(rl, label, options = {}) {
|
|
290
|
+
const suffix =
|
|
291
|
+
options.defaultValue && options.defaultValue.length > 0
|
|
292
|
+
? ` [默认: ${options.defaultValue}]`
|
|
293
|
+
: "";
|
|
294
|
+
const answer = (await rl.question(`${label}${suffix}: `)).trim();
|
|
295
|
+
if (isBackKeyword(answer)) {
|
|
296
|
+
throw new BackToMenuError();
|
|
297
|
+
}
|
|
298
|
+
if (answer.length === 0) {
|
|
299
|
+
return options.defaultValue ?? "";
|
|
300
|
+
}
|
|
301
|
+
return answer;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 读取是/否输入。
|
|
305
|
+
async function askYesNo(rl, label, defaultValue = false) {
|
|
306
|
+
const hint = defaultValue ? "Y/n" : "y/N";
|
|
307
|
+
while (true) {
|
|
308
|
+
const answer = (await rl.question(`${label} [${hint}]: `)).trim().toLowerCase();
|
|
309
|
+
if (isBackKeyword(answer)) {
|
|
310
|
+
throw new BackToMenuError();
|
|
311
|
+
}
|
|
312
|
+
if (!answer) {
|
|
313
|
+
return defaultValue;
|
|
314
|
+
}
|
|
315
|
+
if (["y", "yes"].includes(answer)) {
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
if (["n", "no"].includes(answer)) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
console.log("请输入 y 或 n。");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 循环读取并校验单项输入;空输入会直接返回空字符串。
|
|
326
|
+
async function askValidated(rl, label, validator, options = {}) {
|
|
327
|
+
while (true) {
|
|
328
|
+
const answer = await ask(rl, label, options);
|
|
329
|
+
if (!answer) {
|
|
330
|
+
return "";
|
|
331
|
+
}
|
|
332
|
+
const result = validator(answer);
|
|
333
|
+
if (result.ok) {
|
|
334
|
+
return result.value;
|
|
335
|
+
}
|
|
336
|
+
console.log(`输入有误: ${result.error}`);
|
|
337
|
+
if (options.example) {
|
|
338
|
+
console.log(`示例: ${options.example}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 校验应用名称,规则与 shell 主脚本保持一致。
|
|
344
|
+
function validateAppNameInput(name) {
|
|
345
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
error: "应用名只允许字母、数字、点、下划线、短横线,且必须以字母或数字开头。",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return { ok: true, value: name };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 校验“新的本地应用名”,避免与已有受管应用重名。
|
|
355
|
+
function validateNewAppName(baseArgs, name) {
|
|
356
|
+
const basic = validateAppNameInput(name);
|
|
357
|
+
if (!basic.ok) {
|
|
358
|
+
return basic;
|
|
359
|
+
}
|
|
360
|
+
if (listManagedApps(baseArgs).includes(name)) {
|
|
361
|
+
return { ok: false, error: `受管应用已存在: ${name}` };
|
|
362
|
+
}
|
|
363
|
+
return { ok: true, value: name };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 规范化并校验 hostname,规则尽量与 shell 主脚本保持一致。
|
|
367
|
+
function normalizeHostnameInput(hostname) {
|
|
368
|
+
const normalized = hostname.replace(/\.+$/, "").toLowerCase();
|
|
369
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/.test(normalized)) {
|
|
370
|
+
return { ok: false, error: `主机名非法: ${hostname}` };
|
|
371
|
+
}
|
|
372
|
+
return { ok: true, value: normalized };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 规范化并校验 service,尽量与 shell 主脚本保持一致。
|
|
376
|
+
function normalizeServiceInput(service) {
|
|
377
|
+
if (service === "hello_world" || service.startsWith("http_status:")) {
|
|
378
|
+
return { ok: true, value: service };
|
|
379
|
+
}
|
|
380
|
+
if (service.startsWith("unix:")) {
|
|
381
|
+
return service.length > "unix:".length
|
|
382
|
+
? { ok: true, value: service }
|
|
383
|
+
: { ok: false, error: "unix: 后面必须跟 socket 路径。" };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let normalized = service;
|
|
387
|
+
if (!normalized.includes("://")) {
|
|
388
|
+
if (normalized.includes(":")) {
|
|
389
|
+
normalized = `http://${normalized}`;
|
|
390
|
+
} else {
|
|
391
|
+
return { ok: false, error: "服务地址必须包含协议,或使用 host:port 形式,例如 http://localhost:5173" };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const match = normalized.match(/^([A-Za-z][A-Za-z0-9+.-]*):\/\/(\[[^\]]+\]|[^/:?#]+)(?::([0-9]+))?/);
|
|
396
|
+
if (!match) {
|
|
397
|
+
return { ok: false, error: `服务地址格式非法: ${service}` };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const scheme = match[1];
|
|
401
|
+
const host = match[2];
|
|
402
|
+
if (!["http", "https", "tcp", "ssh", "rdp"].includes(scheme)) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
error: `不支持的服务协议: ${scheme}。支持 http、https、tcp、ssh、rdp、unix:、hello_world、http_status:*`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (!host) {
|
|
409
|
+
return { ok: false, error: `服务地址缺少主机名: ${service}` };
|
|
410
|
+
}
|
|
411
|
+
return { ok: true, value: normalized };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 校验单条 ingress 规则,并返回规范化后的 hostname=service。
|
|
415
|
+
function validateIngressSpecInput(spec) {
|
|
416
|
+
if (!spec.includes("=")) {
|
|
417
|
+
return { ok: false, error: `ingress 规则格式非法: ${spec}。请使用 hostname=service` };
|
|
418
|
+
}
|
|
419
|
+
const hostname = spec.slice(0, spec.indexOf("="));
|
|
420
|
+
const service = spec.slice(spec.indexOf("=") + 1);
|
|
421
|
+
if (!hostname) {
|
|
422
|
+
return { ok: false, error: `ingress 规则缺少 hostname: ${spec}` };
|
|
423
|
+
}
|
|
424
|
+
if (!service) {
|
|
425
|
+
return { ok: false, error: `ingress 规则缺少 service: ${spec}` };
|
|
426
|
+
}
|
|
427
|
+
const hostResult = normalizeHostnameInput(hostname);
|
|
428
|
+
if (!hostResult.ok) {
|
|
429
|
+
return hostResult;
|
|
430
|
+
}
|
|
431
|
+
const serviceResult = normalizeServiceInput(service);
|
|
432
|
+
if (!serviceResult.ok) {
|
|
433
|
+
return serviceResult;
|
|
434
|
+
}
|
|
435
|
+
return { ok: true, value: `${hostResult.value}=${serviceResult.value}` };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 校验批量修改格式,并返回规范化后的 N:hostname=service。
|
|
439
|
+
function validateModifySpecInput(spec) {
|
|
440
|
+
if (!spec.includes(":") || !spec.includes("=")) {
|
|
441
|
+
return { ok: false, error: `批量修改格式非法: ${spec}。请使用 N:hostname=service` };
|
|
442
|
+
}
|
|
443
|
+
const indexPart = spec.slice(0, spec.indexOf(":"));
|
|
444
|
+
const ingressPart = spec.slice(spec.indexOf(":") + 1);
|
|
445
|
+
if (!/^[1-9][0-9]*$/.test(indexPart)) {
|
|
446
|
+
return { ok: false, error: `批量修改序号非法: ${spec}` };
|
|
447
|
+
}
|
|
448
|
+
const ingressResult = validateIngressSpecInput(ingressPart);
|
|
449
|
+
if (!ingressResult.ok) {
|
|
450
|
+
return ingressResult;
|
|
451
|
+
}
|
|
452
|
+
return { ok: true, value: `${indexPart}:${ingressResult.value}` };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 校验 ingress-remove 使用的单项输入。
|
|
456
|
+
function validateRemoveSpecInput(spec) {
|
|
457
|
+
if (/^[1-9][0-9]*$/.test(spec)) {
|
|
458
|
+
return { ok: true, value: spec };
|
|
459
|
+
}
|
|
460
|
+
return normalizeHostnameInput(spec);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// 收集多条 ingress 规则。
|
|
464
|
+
async function collectIngressSpecs(rl) {
|
|
465
|
+
const specs = [];
|
|
466
|
+
const usedHosts = new Set();
|
|
467
|
+
console.log("请输入 ingress 规则,格式为 hostname=service。直接回车结束。");
|
|
468
|
+
while (true) {
|
|
469
|
+
const spec = await askValidated(rl, `规则 ${specs.length + 1}`, validateIngressSpecInput, {
|
|
470
|
+
example: "admin.example.com=http://localhost:5173",
|
|
471
|
+
});
|
|
472
|
+
if (!spec) {
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
const hostname = spec.slice(0, spec.indexOf("="));
|
|
476
|
+
if (usedHosts.has(hostname)) {
|
|
477
|
+
console.log(`输入有误: 本次录入中已经存在主机名 ${hostname}`);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
usedHosts.add(hostname);
|
|
481
|
+
specs.push(spec);
|
|
482
|
+
}
|
|
483
|
+
return specs;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// 收集至少一条 ingress 规则;直接回车不会结束,而是继续提示。
|
|
487
|
+
async function collectRequiredIngressSpecs(rl) {
|
|
488
|
+
while (true) {
|
|
489
|
+
const specs = await collectIngressSpecs(rl);
|
|
490
|
+
if (specs.length > 0) {
|
|
491
|
+
return specs;
|
|
492
|
+
}
|
|
493
|
+
console.log("至少需要提供一条 ingress 规则。输入 back 可取消当前流程。");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 收集多条批量修改规则。
|
|
498
|
+
async function collectModifySpecs(rl) {
|
|
499
|
+
const specs = [];
|
|
500
|
+
const usedIndexes = new Set();
|
|
501
|
+
console.log("请输入批量修改规则,格式为 N:hostname=service。直接回车结束。");
|
|
502
|
+
while (true) {
|
|
503
|
+
const spec = await askValidated(rl, `修改 ${specs.length + 1}`, validateModifySpecInput, {
|
|
504
|
+
example: "1:admin.example.com=http://localhost:5173",
|
|
505
|
+
});
|
|
506
|
+
if (!spec) {
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
const indexPart = spec.slice(0, spec.indexOf(":"));
|
|
510
|
+
if (usedIndexes.has(indexPart)) {
|
|
511
|
+
console.log(`输入有误: 本次录入中已经存在序号 ${indexPart}`);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
usedIndexes.add(indexPart);
|
|
515
|
+
specs.push(spec);
|
|
516
|
+
}
|
|
517
|
+
return specs;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 收集批量删除规则。
|
|
521
|
+
async function collectRemoveSpecs(rl) {
|
|
522
|
+
const specs = [];
|
|
523
|
+
const dedupe = new Set();
|
|
524
|
+
console.log("请输入要删除的规则。输入纯数字表示序号,其他内容按 hostname 处理。直接回车结束。");
|
|
525
|
+
while (true) {
|
|
526
|
+
const spec = await askValidated(rl, `删除 ${specs.length + 1}`, validateRemoveSpecInput, {
|
|
527
|
+
example: "2 或 admin.example.com",
|
|
528
|
+
});
|
|
529
|
+
if (!spec) {
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
if (dedupe.has(spec)) {
|
|
533
|
+
console.log(`输入有误: 本次录入中已经存在 ${spec}`);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
dedupe.add(spec);
|
|
537
|
+
specs.push(spec);
|
|
538
|
+
}
|
|
539
|
+
return specs;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 简单分词,支持单引号和双引号。
|
|
543
|
+
function splitCommandLine(input) {
|
|
544
|
+
const tokens = [];
|
|
545
|
+
let current = "";
|
|
546
|
+
let quote = "";
|
|
547
|
+
|
|
548
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
549
|
+
const ch = input[i];
|
|
550
|
+
if (quote) {
|
|
551
|
+
if (ch === quote) {
|
|
552
|
+
quote = "";
|
|
553
|
+
} else if (ch === "\\" && quote === '"' && i + 1 < input.length) {
|
|
554
|
+
current += input[i + 1];
|
|
555
|
+
i += 1;
|
|
556
|
+
} else {
|
|
557
|
+
current += ch;
|
|
558
|
+
}
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (ch === "'" || ch === '"') {
|
|
563
|
+
quote = ch;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (/\s/.test(ch)) {
|
|
567
|
+
if (current.length > 0) {
|
|
568
|
+
tokens.push(current);
|
|
569
|
+
current = "";
|
|
570
|
+
}
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (ch === "\\" && i + 1 < input.length) {
|
|
574
|
+
current += input[i + 1];
|
|
575
|
+
i += 1;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
current += ch;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (quote) {
|
|
582
|
+
throw new Error("命令行中存在未闭合的引号");
|
|
583
|
+
}
|
|
584
|
+
if (current.length > 0) {
|
|
585
|
+
tokens.push(current);
|
|
586
|
+
}
|
|
587
|
+
return tokens;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 从 shell 的 list 输出中提取受管应用名称。
|
|
591
|
+
function parseManagedApps(output) {
|
|
592
|
+
const names = [];
|
|
593
|
+
for (const line of output.split(/\r?\n/)) {
|
|
594
|
+
const match = line.match(/^([^:]+):\s*状态=/);
|
|
595
|
+
if (match) {
|
|
596
|
+
names.push(match[1].trim());
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return names;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// 从 shell 的 tunnels 输出中提取远端 tunnel 名称。
|
|
603
|
+
function parseRemoteTunnels(output) {
|
|
604
|
+
const names = [];
|
|
605
|
+
for (const line of output.split(/\r?\n/)) {
|
|
606
|
+
const match = line.match(/^(.+?)\s+\| ID=/);
|
|
607
|
+
if (match) {
|
|
608
|
+
names.push(match[1].trim());
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return names;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// 获取当前环境下的全部受管应用名称。
|
|
615
|
+
function listManagedApps(baseArgs) {
|
|
616
|
+
const result = runShell(baseArgs, ["list"], { capture: true });
|
|
617
|
+
if (result.error || result.code !== 0) {
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
return parseManagedApps(result.stdout);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// 获取当前账号下的远端 tunnel 名称。
|
|
624
|
+
function listRemoteTunnels(baseArgs) {
|
|
625
|
+
const result = runShell(baseArgs, ["tunnels"], { capture: true });
|
|
626
|
+
if (result.error || result.code !== 0) {
|
|
627
|
+
return [];
|
|
628
|
+
}
|
|
629
|
+
return parseRemoteTunnels(result.stdout);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 生成“选择应用”的动态菜单。
|
|
633
|
+
function buildAppPickerMenu(baseArgs) {
|
|
634
|
+
const appNames = listManagedApps(baseArgs);
|
|
635
|
+
const items = appNames.map((appName, index) => ({
|
|
636
|
+
value: String(index + 1),
|
|
637
|
+
label: appName,
|
|
638
|
+
hint: "进入该应用的专属子菜单",
|
|
639
|
+
kind: "secondary",
|
|
640
|
+
appName,
|
|
641
|
+
}));
|
|
642
|
+
|
|
643
|
+
if (items.length === 0) {
|
|
644
|
+
items.push({
|
|
645
|
+
value: "1",
|
|
646
|
+
label: "暂无受管应用",
|
|
647
|
+
hint: "可先去“新建与接管”创建或接管一个应用",
|
|
648
|
+
kind: "assist",
|
|
649
|
+
disabled: true,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
items.push({
|
|
654
|
+
value: "0",
|
|
655
|
+
label: "返回主菜单",
|
|
656
|
+
hint: "回到一级菜单",
|
|
657
|
+
kind: "back",
|
|
658
|
+
back: true,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
key: "app_picker",
|
|
663
|
+
title: "选择应用",
|
|
664
|
+
subtitle: items.length > 1 ? "先选一个受管应用,再进入专属操作菜单。" : "当前还没有受管应用。",
|
|
665
|
+
breadcrumb: buildBreadcrumb("app_picker"),
|
|
666
|
+
items,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 生成“当前应用”的动态菜单。
|
|
671
|
+
function buildAppDetailMenu(appName) {
|
|
672
|
+
return {
|
|
673
|
+
key: "app_detail",
|
|
674
|
+
title: `应用:${appName}`,
|
|
675
|
+
subtitle: "围绕当前应用执行查看、运行、配置和 ingress 操作。",
|
|
676
|
+
breadcrumb: buildBreadcrumb("app_detail"),
|
|
677
|
+
items: [
|
|
678
|
+
{ value: "1", label: "查看应用概览", hint: "查看当前应用的元数据和配置 (show)", kind: "secondary", action: "4", group: "查看" },
|
|
679
|
+
{ value: "2", label: "查看运行状态", hint: "查看当前应用或 Tunnel 的运行状态 (status)", kind: "secondary", action: "13", group: "查看" },
|
|
680
|
+
{ value: "3", label: "查看日志", hint: "查看或跟随当前应用日志 (logs)", kind: "assist", action: "14", group: "查看" },
|
|
681
|
+
{ value: "4", label: "启动应用", hint: "启动当前应用对应的 cloudflared 进程 (start)", kind: "primary", action: "10", group: "运行" },
|
|
682
|
+
{ value: "5", label: "停止应用", hint: "停止当前应用对应的 cloudflared 进程 (stop)", kind: "danger", action: "11", group: "运行" },
|
|
683
|
+
{ value: "6", label: "重启应用", hint: "重启当前应用对应的 cloudflared 进程 (restart)", kind: "primary", action: "12", group: "运行" },
|
|
684
|
+
{ value: "7", label: "查看 ingress 列表", hint: "列出当前应用的所有 ingress 规则 (ingress-list)", kind: "secondary", action: "22", group: "Ingress" },
|
|
685
|
+
{ value: "8", label: "新增 ingress", hint: "为当前应用追加 ingress 规则 (ingress-add)", kind: "primary", action: "8", group: "Ingress" },
|
|
686
|
+
{ value: "9", label: "修改 ingress", hint: "按序号批量修改 ingress 规则 (modify)", kind: "primary", action: "7", group: "Ingress" },
|
|
687
|
+
{ value: "10", label: "删除 ingress", hint: "按序号或主机名删除 ingress 规则 (ingress-remove)", kind: "danger", action: "9", group: "Ingress" },
|
|
688
|
+
{ value: "11", label: "激活为默认配置", hint: "把当前应用配置复制为默认 config.yml (activate)", kind: "assist", action: "15", group: "配置与管理" },
|
|
689
|
+
{ value: "12", label: "删除应用", hint: "归档当前应用,可选同时删除远端 Tunnel (delete)", kind: "danger", action: "16", group: "配置与管理" },
|
|
690
|
+
{ value: "0", label: "返回应用列表", hint: "切换到其他应用", kind: "back", back: true },
|
|
691
|
+
],
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// 生成当前菜单对象。
|
|
696
|
+
function buildMenu(baseArgs, menuKey, appName = "") {
|
|
697
|
+
if (menuKey === "app_picker") {
|
|
698
|
+
return buildAppPickerMenu(baseArgs);
|
|
699
|
+
}
|
|
700
|
+
if (menuKey === "app_detail") {
|
|
701
|
+
return buildAppDetailMenu(appName);
|
|
702
|
+
}
|
|
703
|
+
return { ...MENUS[menuKey], key: menuKey, breadcrumb: buildBreadcrumb(menuKey, appName) };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// 把 ingress 规则渲染成适合预览的多行文本。
|
|
707
|
+
function renderIngressPreview(specs) {
|
|
708
|
+
if (specs.length === 0) {
|
|
709
|
+
return [" (未填写)"];
|
|
710
|
+
}
|
|
711
|
+
return specs.map((spec, index) => {
|
|
712
|
+
const separatorIndex = spec.indexOf("=");
|
|
713
|
+
const hostname = spec.slice(0, separatorIndex);
|
|
714
|
+
const service = spec.slice(separatorIndex + 1);
|
|
715
|
+
return ` ${index + 1}. ${hostname} -> ${service}`;
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// 解析 ingress-list 输出为结构化规则数组。
|
|
720
|
+
function parseIngressRules(output) {
|
|
721
|
+
const rules = [];
|
|
722
|
+
for (const line of output.split(/\r?\n/)) {
|
|
723
|
+
const match = line.match(/^\s*([0-9]+)\.\s+(.+?)\s+->\s+(.+)$/);
|
|
724
|
+
if (match) {
|
|
725
|
+
rules.push({
|
|
726
|
+
index: Number(match[1]),
|
|
727
|
+
hostname: match[2].trim(),
|
|
728
|
+
service: match[3].trim(),
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return rules;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// 读取某个应用当前的 ingress 规则。
|
|
736
|
+
function loadCurrentIngressRules(baseArgs, name) {
|
|
737
|
+
const result = runShell(baseArgs, ["ingress-list", name], { capture: true });
|
|
738
|
+
if (result.error || result.code !== 0) {
|
|
739
|
+
const message = (result.stderr || result.stdout || "无法读取 ingress 规则").trim();
|
|
740
|
+
console.log(message);
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
return parseIngressRules(result.stdout);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// 将 hostname=service 规格转成结构化规则。
|
|
747
|
+
function rulesFromIngressSpecs(specs) {
|
|
748
|
+
return specs.map((spec) => {
|
|
749
|
+
const separatorIndex = spec.indexOf("=");
|
|
750
|
+
return {
|
|
751
|
+
hostname: spec.slice(0, separatorIndex),
|
|
752
|
+
service: spec.slice(separatorIndex + 1),
|
|
753
|
+
};
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// 预演 modify 后的规则集合,并返回预览信息。
|
|
758
|
+
function previewModifyRules(currentRules, specs) {
|
|
759
|
+
const nextRules = currentRules.map((rule) => ({ ...rule }));
|
|
760
|
+
|
|
761
|
+
for (const spec of specs) {
|
|
762
|
+
const separatorIndex = spec.indexOf(":");
|
|
763
|
+
const index = Number(spec.slice(0, separatorIndex));
|
|
764
|
+
const ingressSpec = spec.slice(separatorIndex + 1);
|
|
765
|
+
const nextRule = rulesFromIngressSpecs([ingressSpec])[0];
|
|
766
|
+
if (index < 1 || index > nextRules.length) {
|
|
767
|
+
return { ok: false, error: `修改序号超出范围: ${index}。当前共有 ${nextRules.length} 条规则。` };
|
|
768
|
+
}
|
|
769
|
+
nextRules[index - 1] = { index, ...nextRule };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const seenHosts = new Set();
|
|
773
|
+
for (const rule of nextRules) {
|
|
774
|
+
if (seenHosts.has(rule.hostname)) {
|
|
775
|
+
return { ok: false, error: `修改后出现重复主机名: ${rule.hostname}` };
|
|
776
|
+
}
|
|
777
|
+
seenHosts.add(rule.hostname);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return { ok: true, nextRules };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// 预演 ingress-add 后的规则集合,并返回预览信息。
|
|
784
|
+
function previewAddRules(currentRules, specs) {
|
|
785
|
+
const nextRules = currentRules.map((rule) => ({ ...rule }));
|
|
786
|
+
const additions = rulesFromIngressSpecs(specs);
|
|
787
|
+
const seenHosts = new Set(currentRules.map((rule) => rule.hostname));
|
|
788
|
+
|
|
789
|
+
for (const addition of additions) {
|
|
790
|
+
if (seenHosts.has(addition.hostname)) {
|
|
791
|
+
return { ok: false, error: `主机名已存在,无法重复新增: ${addition.hostname}` };
|
|
792
|
+
}
|
|
793
|
+
seenHosts.add(addition.hostname);
|
|
794
|
+
nextRules.push({ index: nextRules.length + 1, ...addition });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return { ok: true, nextRules, additions };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 预演 ingress-remove 后的规则集合,并返回预览信息。
|
|
801
|
+
function previewRemoveRules(currentRules, specs) {
|
|
802
|
+
const removeIndexes = new Set();
|
|
803
|
+
|
|
804
|
+
for (const spec of specs) {
|
|
805
|
+
if (/^[1-9][0-9]*$/.test(spec)) {
|
|
806
|
+
const index = Number(spec);
|
|
807
|
+
if (index < 1 || index > currentRules.length) {
|
|
808
|
+
return { ok: false, error: `删除序号超出范围: ${index}。当前共有 ${currentRules.length} 条规则。` };
|
|
809
|
+
}
|
|
810
|
+
removeIndexes.add(index);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const target = currentRules.find((rule) => rule.hostname === spec);
|
|
815
|
+
if (!target) {
|
|
816
|
+
return { ok: false, error: `找不到要删除的主机名: ${spec}` };
|
|
817
|
+
}
|
|
818
|
+
removeIndexes.add(target.index);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const removedRules = currentRules.filter((rule) => removeIndexes.has(rule.index));
|
|
822
|
+
const nextRules = currentRules
|
|
823
|
+
.filter((rule) => !removeIndexes.has(rule.index))
|
|
824
|
+
.map((rule, index) => ({ ...rule, index: index + 1 }));
|
|
825
|
+
|
|
826
|
+
if (nextRules.length === 0) {
|
|
827
|
+
return { ok: false, error: "不能删除全部 ingress 规则。" };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return { ok: true, nextRules, removedRules };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// 渲染结构化规则列表。
|
|
834
|
+
function renderRuleObjects(rules) {
|
|
835
|
+
if (!rules || rules.length === 0) {
|
|
836
|
+
return [" (无)"];
|
|
837
|
+
}
|
|
838
|
+
return rules.map((rule) => ` ${rule.index}. ${rule.hostname} -> ${rule.service}`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// 把单个 shell 参数渲染为可直接复制执行的形式。
|
|
842
|
+
function shellEscapeArg(value) {
|
|
843
|
+
if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) {
|
|
844
|
+
return value;
|
|
845
|
+
}
|
|
846
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// 渲染命令预览。
|
|
850
|
+
function renderCommandPreview(baseArgs, commandArgs) {
|
|
851
|
+
const parts = ["./cloudflared_manager.sh", ...baseArgs, ...commandArgs].map(shellEscapeArg);
|
|
852
|
+
return [` ${parts.join(" ")}`];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// 渲染 modify 的差异摘要。
|
|
856
|
+
function renderModifyDiffLines(currentRules, nextRules) {
|
|
857
|
+
const lines = [];
|
|
858
|
+
for (let i = 0; i < currentRules.length; i += 1) {
|
|
859
|
+
const current = currentRules[i];
|
|
860
|
+
const next = nextRules[i];
|
|
861
|
+
if (!next) {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
if (current.hostname !== next.hostname || current.service !== next.service) {
|
|
865
|
+
lines.push(colorize(` [修改前] ~ ${current.index}. ${current.hostname} -> ${current.service}`, COLORS.bold, COLORS.yellow));
|
|
866
|
+
lines.push(colorize(` [修改后] ~ ${next.index}. ${next.hostname} -> ${next.service}`, COLORS.bold, COLORS.yellow));
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (lines.length === 0) {
|
|
870
|
+
lines.push(colorize(" (本次修改不会改变现有规则)", COLORS.dim, COLORS.gray));
|
|
871
|
+
}
|
|
872
|
+
return lines;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// 渲染新增规则摘要。
|
|
876
|
+
function renderAddDiffLines(additions, startIndex) {
|
|
877
|
+
if (!additions || additions.length === 0) {
|
|
878
|
+
return [colorize(" (本次没有新增规则)", COLORS.dim, COLORS.gray)];
|
|
879
|
+
}
|
|
880
|
+
return additions.map((rule, index) =>
|
|
881
|
+
colorize(` [新增] + ${startIndex + index}. ${rule.hostname} -> ${rule.service}`, COLORS.bold, COLORS.green),
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// 渲染删除规则摘要。
|
|
886
|
+
function renderRemoveDiffLines(removedRules) {
|
|
887
|
+
if (!removedRules || removedRules.length === 0) {
|
|
888
|
+
return [colorize(" (本次没有删除规则)", COLORS.dim, COLORS.gray)];
|
|
889
|
+
}
|
|
890
|
+
return removedRules.map((rule) =>
|
|
891
|
+
colorize(` [删除] - ${rule.index}. ${rule.hostname} -> ${rule.service}`, COLORS.bold, COLORS.red),
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// 构造 add/adopt 的汇总确认菜单。
|
|
896
|
+
function buildSetupReviewMenu(type, baseArgs, state) {
|
|
897
|
+
const isAdd = type === "add";
|
|
898
|
+
const title = isAdd ? "确认创建新 Tunnel" : "确认接管现有 Tunnel";
|
|
899
|
+
const subtitle = "先核对当前表单内容,再决定执行、修改或取消。";
|
|
900
|
+
const ingressLines = renderIngressPreview(state.specs);
|
|
901
|
+
const commandArgs = isAdd
|
|
902
|
+
? ["add", state.name, "--tunnel-name", state.tunnelName, ...buildIngressArgs(state.specs)]
|
|
903
|
+
: ["adopt", state.name, "--tunnel-name", state.tunnelName, ...buildIngressArgs(state.specs)];
|
|
904
|
+
const prelude = [
|
|
905
|
+
colorize(`本地应用名: ${state.name}`, COLORS.bold, COLORS.white),
|
|
906
|
+
colorize(`远端 Tunnel 名称: ${state.tunnelName}`, COLORS.bold, COLORS.white),
|
|
907
|
+
colorize(`ingress 规则数: ${state.specs.length}`, COLORS.bold, COLORS.white),
|
|
908
|
+
...ingressLines.map((line) => colorize(line, COLORS.dim, COLORS.gray)),
|
|
909
|
+
];
|
|
910
|
+
|
|
911
|
+
if (isAdd) {
|
|
912
|
+
prelude.push(colorize(`立即启动: ${formatEnabled(state.start)}`, COLORS.dim, COLORS.gray));
|
|
913
|
+
prelude.push(colorize(`激活为默认配置: ${formatEnabled(state.activate)}`, COLORS.dim, COLORS.gray));
|
|
914
|
+
if (state.start) commandArgs.push("--start");
|
|
915
|
+
} else {
|
|
916
|
+
prelude.push(colorize(`同时确保 DNS 路由存在: ${formatEnabled(state.ensureDns)}`, COLORS.dim, COLORS.gray));
|
|
917
|
+
prelude.push(colorize(`激活为默认配置: ${formatEnabled(state.activate)}`, COLORS.dim, COLORS.gray));
|
|
918
|
+
if (state.ensureDns) commandArgs.push("--ensure-dns");
|
|
919
|
+
}
|
|
920
|
+
if (state.activate) commandArgs.push("--activate");
|
|
921
|
+
|
|
922
|
+
prelude.push(colorize("命令预览:", COLORS.bold, COLORS.cyan));
|
|
923
|
+
prelude.push(...renderCommandPreview(baseArgs, commandArgs).map((line) => colorize(line, COLORS.dim, COLORS.gray)));
|
|
924
|
+
|
|
925
|
+
const items = [
|
|
926
|
+
{ value: "1", label: isAdd ? "执行创建" : "执行接管", hint: "调用底层 shell 脚本真正落地", kind: "primary" },
|
|
927
|
+
{ value: "2", label: "重新填写本地应用名", hint: `当前: ${state.name}`, kind: "secondary" },
|
|
928
|
+
{ value: "3", label: "重新填写远端 Tunnel 名称", hint: `当前: ${state.tunnelName}`, kind: "secondary" },
|
|
929
|
+
{ value: "4", label: "重新录入 ingress 规则", hint: `当前共 ${state.specs.length} 条`, kind: "secondary" },
|
|
930
|
+
{
|
|
931
|
+
value: "5",
|
|
932
|
+
label: isAdd ? "切换“立即启动”" : "切换“确保 DNS 路由存在”",
|
|
933
|
+
hint: `当前: ${isAdd ? formatEnabled(state.start) : formatEnabled(state.ensureDns)}`,
|
|
934
|
+
kind: "assist",
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
value: "6",
|
|
938
|
+
label: "切换“激活为默认配置”",
|
|
939
|
+
hint: `当前: ${formatEnabled(state.activate)}`,
|
|
940
|
+
kind: "assist",
|
|
941
|
+
},
|
|
942
|
+
{ value: "0", label: "取消当前流程", hint: "不执行这次 add/adopt", kind: "danger", back: true },
|
|
943
|
+
];
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
key: `${type}_review`,
|
|
947
|
+
title,
|
|
948
|
+
subtitle,
|
|
949
|
+
breadcrumb: "主菜单 / 新建与接管",
|
|
950
|
+
prelude,
|
|
951
|
+
items,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// 收集至少一条批量修改规则。
|
|
956
|
+
async function collectRequiredModifySpecs(rl) {
|
|
957
|
+
while (true) {
|
|
958
|
+
const specs = await collectModifySpecs(rl);
|
|
959
|
+
if (specs.length > 0) {
|
|
960
|
+
return specs;
|
|
961
|
+
}
|
|
962
|
+
console.log("至少需要提供一条修改规则。输入 back 可取消当前流程。");
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// 收集至少一条删除规则。
|
|
967
|
+
async function collectRequiredRemoveSpecs(rl) {
|
|
968
|
+
while (true) {
|
|
969
|
+
const specs = await collectRemoveSpecs(rl);
|
|
970
|
+
if (specs.length > 0) {
|
|
971
|
+
return specs;
|
|
972
|
+
}
|
|
973
|
+
console.log("至少需要提供一条删除规则。输入 back 可取消当前流程。");
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// 构造 ingress 变更确认菜单。
|
|
978
|
+
function buildIngressChangeReviewMenu(type, baseArgs, state, preview) {
|
|
979
|
+
const actionLabelMap = {
|
|
980
|
+
modify: "确认修改 ingress",
|
|
981
|
+
add: "确认新增 ingress",
|
|
982
|
+
remove: "确认删除 ingress",
|
|
983
|
+
};
|
|
984
|
+
const specLabelMap = {
|
|
985
|
+
modify: "本次修改规则",
|
|
986
|
+
add: "本次新增规则",
|
|
987
|
+
remove: "本次删除规则",
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
const prelude = [
|
|
991
|
+
colorize(`目标应用: ${state.name}`, COLORS.bold, COLORS.white),
|
|
992
|
+
colorize("当前 ingress:", COLORS.bold, COLORS.white),
|
|
993
|
+
...renderRuleObjects(state.currentRules).map((line) => colorize(line, COLORS.dim, COLORS.gray)),
|
|
994
|
+
colorize(specLabelMap[type], COLORS.bold, COLORS.white),
|
|
995
|
+
];
|
|
996
|
+
|
|
997
|
+
if (type === "modify" || type === "add") {
|
|
998
|
+
prelude.push(...renderIngressPreview(state.specs).map((line) => colorize(line, COLORS.dim, COLORS.gray)));
|
|
999
|
+
} else {
|
|
1000
|
+
prelude.push(...state.specs.map((spec) => colorize(` - ${spec}`, COLORS.dim, COLORS.gray)));
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
prelude.push(colorize("变更摘要:", COLORS.bold, COLORS.cyan));
|
|
1004
|
+
if (type === "remove") {
|
|
1005
|
+
prelude.push(...renderRemoveDiffLines(preview.removedRules));
|
|
1006
|
+
} else if (type === "add") {
|
|
1007
|
+
prelude.push(...renderAddDiffLines(preview.additions, state.currentRules.length + 1));
|
|
1008
|
+
} else {
|
|
1009
|
+
prelude.push(...renderModifyDiffLines(state.currentRules, preview.nextRules));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
prelude.push(colorize("变更后:", COLORS.bold, COLORS.white));
|
|
1013
|
+
prelude.push(...renderRuleObjects(preview.nextRules).map((line) => colorize(line, COLORS.dim, COLORS.gray)));
|
|
1014
|
+
prelude.push(colorize(`暂不重启: ${formatEnabled(state.noRestart)}`, COLORS.dim, COLORS.gray));
|
|
1015
|
+
|
|
1016
|
+
let commandArgs;
|
|
1017
|
+
if (type === "modify") {
|
|
1018
|
+
commandArgs = ["modify", state.name];
|
|
1019
|
+
for (const spec of state.specs) {
|
|
1020
|
+
commandArgs.push("--set", spec);
|
|
1021
|
+
}
|
|
1022
|
+
} else if (type === "add") {
|
|
1023
|
+
commandArgs = ["ingress-add", state.name, ...buildIngressArgs(state.specs)];
|
|
1024
|
+
} else {
|
|
1025
|
+
commandArgs = ["ingress-remove", state.name, ...buildRemoveArgs(state.specs)];
|
|
1026
|
+
}
|
|
1027
|
+
if (state.noRestart) {
|
|
1028
|
+
commandArgs.push("--no-restart");
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
prelude.push(colorize("命令预览:", COLORS.bold, COLORS.cyan));
|
|
1032
|
+
prelude.push(...renderCommandPreview(baseArgs, commandArgs).map((line) => colorize(line, COLORS.dim, COLORS.gray)));
|
|
1033
|
+
|
|
1034
|
+
const items = [
|
|
1035
|
+
{ value: "1", label: "执行变更", hint: "调用底层 shell 脚本真正落地", kind: "primary" },
|
|
1036
|
+
{
|
|
1037
|
+
value: "2",
|
|
1038
|
+
label: type === "modify" ? "重新录入修改规则" : type === "add" ? "重新录入新增规则" : "重新录入删除规则",
|
|
1039
|
+
hint: `当前共 ${state.specs.length} 条`,
|
|
1040
|
+
kind: "secondary",
|
|
1041
|
+
},
|
|
1042
|
+
{ value: "3", label: "切换“暂不重启”", hint: `当前: ${formatEnabled(state.noRestart)}`, kind: "assist" },
|
|
1043
|
+
{ value: "0", label: "取消当前流程", hint: "不执行这次变更", kind: "danger", back: true },
|
|
1044
|
+
];
|
|
1045
|
+
|
|
1046
|
+
return {
|
|
1047
|
+
key: `${type}_ingress_review`,
|
|
1048
|
+
title: actionLabelMap[type],
|
|
1049
|
+
subtitle: "先预览变更前后差异,再决定是否执行。",
|
|
1050
|
+
breadcrumb: `主菜单 / 现有应用管理 / ${state.name}`,
|
|
1051
|
+
prelude,
|
|
1052
|
+
items,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// 交互式执行 modify 表单,先预演变更,再决定是否执行。
|
|
1057
|
+
async function runModifyWizard(rl, baseArgs, name) {
|
|
1058
|
+
const currentRules = loadCurrentIngressRules(baseArgs, name);
|
|
1059
|
+
if (!currentRules) {
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const state = {
|
|
1064
|
+
name,
|
|
1065
|
+
currentRules,
|
|
1066
|
+
specs: await collectRequiredModifySpecs(rl),
|
|
1067
|
+
noRestart: true,
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
while (true) {
|
|
1071
|
+
const preview = previewModifyRules(state.currentRules, state.specs);
|
|
1072
|
+
if (!preview.ok) {
|
|
1073
|
+
console.log(`输入有误: ${preview.error}`);
|
|
1074
|
+
state.specs = await collectRequiredModifySpecs(rl);
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const selection = await chooseMenuItem(rl, baseArgs, buildIngressChangeReviewMenu("modify", baseArgs, state, preview));
|
|
1079
|
+
switch (selection) {
|
|
1080
|
+
case "1":
|
|
1081
|
+
return state;
|
|
1082
|
+
case "2":
|
|
1083
|
+
state.specs = await collectRequiredModifySpecs(rl);
|
|
1084
|
+
break;
|
|
1085
|
+
case "3":
|
|
1086
|
+
state.noRestart = !state.noRestart;
|
|
1087
|
+
break;
|
|
1088
|
+
case "0":
|
|
1089
|
+
return null;
|
|
1090
|
+
default:
|
|
1091
|
+
console.log("无效选项,请重新输入。");
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// 交互式执行 ingress-add 表单,先预演变更,再决定是否执行。
|
|
1098
|
+
async function runIngressAddWizard(rl, baseArgs, name) {
|
|
1099
|
+
const currentRules = loadCurrentIngressRules(baseArgs, name);
|
|
1100
|
+
if (!currentRules) {
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const state = {
|
|
1105
|
+
name,
|
|
1106
|
+
currentRules,
|
|
1107
|
+
specs: await collectRequiredIngressSpecs(rl),
|
|
1108
|
+
noRestart: true,
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
while (true) {
|
|
1112
|
+
const preview = previewAddRules(state.currentRules, state.specs);
|
|
1113
|
+
if (!preview.ok) {
|
|
1114
|
+
console.log(`输入有误: ${preview.error}`);
|
|
1115
|
+
state.specs = await collectRequiredIngressSpecs(rl);
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const selection = await chooseMenuItem(rl, baseArgs, buildIngressChangeReviewMenu("add", baseArgs, state, preview));
|
|
1120
|
+
switch (selection) {
|
|
1121
|
+
case "1":
|
|
1122
|
+
return state;
|
|
1123
|
+
case "2":
|
|
1124
|
+
state.specs = await collectRequiredIngressSpecs(rl);
|
|
1125
|
+
break;
|
|
1126
|
+
case "3":
|
|
1127
|
+
state.noRestart = !state.noRestart;
|
|
1128
|
+
break;
|
|
1129
|
+
case "0":
|
|
1130
|
+
return null;
|
|
1131
|
+
default:
|
|
1132
|
+
console.log("无效选项,请重新输入。");
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// 交互式执行 ingress-remove 表单,先预演变更,再决定是否执行。
|
|
1139
|
+
async function runIngressRemoveWizard(rl, baseArgs, name) {
|
|
1140
|
+
const currentRules = loadCurrentIngressRules(baseArgs, name);
|
|
1141
|
+
if (!currentRules) {
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const state = {
|
|
1146
|
+
name,
|
|
1147
|
+
currentRules,
|
|
1148
|
+
specs: await collectRequiredRemoveSpecs(rl),
|
|
1149
|
+
noRestart: true,
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
while (true) {
|
|
1153
|
+
const preview = previewRemoveRules(state.currentRules, state.specs);
|
|
1154
|
+
if (!preview.ok) {
|
|
1155
|
+
console.log(`输入有误: ${preview.error}`);
|
|
1156
|
+
state.specs = await collectRequiredRemoveSpecs(rl);
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const selection = await chooseMenuItem(rl, baseArgs, buildIngressChangeReviewMenu("remove", baseArgs, state, preview));
|
|
1161
|
+
switch (selection) {
|
|
1162
|
+
case "1":
|
|
1163
|
+
return state;
|
|
1164
|
+
case "2":
|
|
1165
|
+
state.specs = await collectRequiredRemoveSpecs(rl);
|
|
1166
|
+
break;
|
|
1167
|
+
case "3":
|
|
1168
|
+
state.noRestart = !state.noRestart;
|
|
1169
|
+
break;
|
|
1170
|
+
case "0":
|
|
1171
|
+
return null;
|
|
1172
|
+
default:
|
|
1173
|
+
console.log("无效选项,请重新输入。");
|
|
1174
|
+
break;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// 生成菜单各行文本,可用于普通输出或方向键模式。
|
|
1180
|
+
function menuLines(baseArgs, menu, selectedIndex = -1, numberBuffer = "", pendingG = false, commandBuffer = "") {
|
|
1181
|
+
const menuSubtitle = colorize(truncateDisplayText(menu.subtitle, terminalColumns() - 1), COLORS.bold, COLORS.white);
|
|
1182
|
+
const baseLine = baseArgs.length > 0
|
|
1183
|
+
? colorize(truncateDisplayText(`设置: ${baseArgs.join(" ")}`, terminalColumns() - 1), COLORS.dim, COLORS.white)
|
|
1184
|
+
: "";
|
|
1185
|
+
const lines = ["", ...renderBanner("cloudflared-manager", menu.title), ""];
|
|
1186
|
+
|
|
1187
|
+
if (menu.breadcrumb) {
|
|
1188
|
+
lines.push(colorize(menu.breadcrumb, COLORS.bold, COLORS.blue));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
lines.push(menuSubtitle);
|
|
1192
|
+
if (baseLine) {
|
|
1193
|
+
lines.push(baseLine);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (Array.isArray(menu.prelude) && menu.prelude.length > 0) {
|
|
1197
|
+
lines.push("");
|
|
1198
|
+
lines.push(...menu.prelude);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
let lastGroup = "";
|
|
1202
|
+
menu.items.forEach((item, index) => {
|
|
1203
|
+
if (item.back && index > 0 && lines[lines.length - 1] !== "") {
|
|
1204
|
+
lines.push("");
|
|
1205
|
+
}
|
|
1206
|
+
if (item.group && item.group !== lastGroup) {
|
|
1207
|
+
lines.push("");
|
|
1208
|
+
lines.push(colorize(item.group, COLORS.dim, COLORS.cyan));
|
|
1209
|
+
lastGroup = item.group;
|
|
1210
|
+
}
|
|
1211
|
+
const isSelected = selectedIndex === index;
|
|
1212
|
+
const displayValue = formatMenuValue(item.value);
|
|
1213
|
+
const lineText = `${isSelected ? "›" : " "} ${displayValue}. ${item.label}`;
|
|
1214
|
+
|
|
1215
|
+
if (isSelected && supportsColor()) {
|
|
1216
|
+
lines.push(colorize(lineText, COLORS.selectedBg, COLORS.brightWhite, COLORS.bold));
|
|
1217
|
+
} else {
|
|
1218
|
+
lines.push(`${isSelected ? "›" : " "} ${colorize(`${displayValue}.`, COLORS.dim, COLORS.gray)} ${colorize(item.label, ...itemColors(item.kind))}`);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
if (selectedIndex >= 0) {
|
|
1223
|
+
const currentItem = menu.items[selectedIndex];
|
|
1224
|
+
lines.push("");
|
|
1225
|
+
const statusText = numberBuffer
|
|
1226
|
+
? `输入 · ${numberBuffer} · 回车确认`
|
|
1227
|
+
: commandBuffer
|
|
1228
|
+
? `返回 · ${commandBuffer} · 匹配 back / 返回 / .. 后返回上一级`
|
|
1229
|
+
: pendingG
|
|
1230
|
+
? `说明 · ${currentItem.hint} · 再按一次 g 到首项`
|
|
1231
|
+
: `说明 · ${currentItem.hint} · ↑↓/j/k · Enter · back · 数字/01`;
|
|
1232
|
+
const fittedStatusText = truncateDisplayText(statusText, terminalColumns() - 1);
|
|
1233
|
+
const statusLine = numberBuffer || commandBuffer || pendingG
|
|
1234
|
+
? colorize(fittedStatusText, COLORS.bold, COLORS.yellow)
|
|
1235
|
+
: colorize(fittedStatusText, COLORS.dim, COLORS.white);
|
|
1236
|
+
lines.push(statusLine);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
return lines;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// 交互式执行 add 表单,先收集,再给出汇总确认。
|
|
1243
|
+
async function runAddWizard(rl, baseArgs) {
|
|
1244
|
+
const state = {
|
|
1245
|
+
name: "",
|
|
1246
|
+
tunnelName: "",
|
|
1247
|
+
specs: [],
|
|
1248
|
+
start: false,
|
|
1249
|
+
activate: false,
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
state.name = await askValidated(rl, "新应用名称", (value) => validateNewAppName(baseArgs, value), {
|
|
1253
|
+
example: "wchros-admin-dev",
|
|
1254
|
+
});
|
|
1255
|
+
if (!state.name) {
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
state.tunnelName = await ask(rl, "远端 tunnel 名称", { defaultValue: state.name });
|
|
1259
|
+
state.specs = await collectRequiredIngressSpecs(rl);
|
|
1260
|
+
|
|
1261
|
+
while (true) {
|
|
1262
|
+
const selection = await chooseMenuItem(rl, baseArgs, buildSetupReviewMenu("add", baseArgs, state));
|
|
1263
|
+
switch (selection) {
|
|
1264
|
+
case "1":
|
|
1265
|
+
return state;
|
|
1266
|
+
case "2":
|
|
1267
|
+
state.name = await askValidated(rl, "新应用名称", (value) => validateNewAppName(baseArgs, value), {
|
|
1268
|
+
defaultValue: state.name,
|
|
1269
|
+
example: "wchros-admin-dev",
|
|
1270
|
+
});
|
|
1271
|
+
if (!state.name) {
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
break;
|
|
1275
|
+
case "3":
|
|
1276
|
+
state.tunnelName = await ask(rl, "远端 tunnel 名称", { defaultValue: state.tunnelName || state.name });
|
|
1277
|
+
break;
|
|
1278
|
+
case "4":
|
|
1279
|
+
state.specs = await collectRequiredIngressSpecs(rl);
|
|
1280
|
+
break;
|
|
1281
|
+
case "5":
|
|
1282
|
+
state.start = !state.start;
|
|
1283
|
+
break;
|
|
1284
|
+
case "6":
|
|
1285
|
+
state.activate = !state.activate;
|
|
1286
|
+
break;
|
|
1287
|
+
case "0":
|
|
1288
|
+
return null;
|
|
1289
|
+
default:
|
|
1290
|
+
console.log("无效选项,请重新输入。");
|
|
1291
|
+
break;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// 交互式执行 adopt 表单,先收集,再给出汇总确认。
|
|
1297
|
+
async function runAdoptWizard(rl, baseArgs) {
|
|
1298
|
+
const state = {
|
|
1299
|
+
name: "",
|
|
1300
|
+
tunnelName: "",
|
|
1301
|
+
specs: [],
|
|
1302
|
+
ensureDns: false,
|
|
1303
|
+
activate: false,
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
state.name = await askValidated(rl, "本地应用名称", (value) => validateNewAppName(baseArgs, value), {
|
|
1307
|
+
example: "wchros-admin-dev",
|
|
1308
|
+
});
|
|
1309
|
+
if (!state.name) {
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
state.tunnelName = await chooseRemoteTunnelName(rl, baseArgs);
|
|
1313
|
+
if (!state.tunnelName) {
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
state.specs = await collectRequiredIngressSpecs(rl);
|
|
1317
|
+
|
|
1318
|
+
while (true) {
|
|
1319
|
+
const selection = await chooseMenuItem(rl, baseArgs, buildSetupReviewMenu("adopt", baseArgs, state));
|
|
1320
|
+
switch (selection) {
|
|
1321
|
+
case "1":
|
|
1322
|
+
return state;
|
|
1323
|
+
case "2":
|
|
1324
|
+
state.name = await askValidated(rl, "本地应用名称", (value) => validateNewAppName(baseArgs, value), {
|
|
1325
|
+
defaultValue: state.name,
|
|
1326
|
+
example: "wchros-admin-dev",
|
|
1327
|
+
});
|
|
1328
|
+
if (!state.name) {
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
break;
|
|
1332
|
+
case "3":
|
|
1333
|
+
state.tunnelName = await chooseRemoteTunnelName(rl, baseArgs);
|
|
1334
|
+
if (!state.tunnelName) {
|
|
1335
|
+
return null;
|
|
1336
|
+
}
|
|
1337
|
+
break;
|
|
1338
|
+
case "4":
|
|
1339
|
+
state.specs = await collectRequiredIngressSpecs(rl);
|
|
1340
|
+
break;
|
|
1341
|
+
case "5":
|
|
1342
|
+
state.ensureDns = !state.ensureDns;
|
|
1343
|
+
break;
|
|
1344
|
+
case "6":
|
|
1345
|
+
state.activate = !state.activate;
|
|
1346
|
+
break;
|
|
1347
|
+
case "0":
|
|
1348
|
+
return null;
|
|
1349
|
+
default:
|
|
1350
|
+
console.log("无效选项,请重新输入。");
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// 渲染主菜单。
|
|
1357
|
+
function printMenu(baseArgs, menu) {
|
|
1358
|
+
console.log(menuLines(baseArgs, menu).join("\n"));
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// 通过方向键或数字在当前菜单中选择一个动作。
|
|
1362
|
+
async function chooseMenuItem(rl, baseArgs, menu) {
|
|
1363
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== "function") {
|
|
1364
|
+
printMenu(baseArgs, menu);
|
|
1365
|
+
const answer = await ask(rl, "请选择操作");
|
|
1366
|
+
return isBackKeyword(answer) ? "0" : answer;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
emitKeypressEvents(process.stdin);
|
|
1370
|
+
rl.pause();
|
|
1371
|
+
|
|
1372
|
+
return new Promise((resolve, reject) => {
|
|
1373
|
+
let selectedIndex = 0;
|
|
1374
|
+
let numberBuffer = "";
|
|
1375
|
+
let pendingG = false;
|
|
1376
|
+
let commandBuffer = "";
|
|
1377
|
+
let renderedLineCount = 0;
|
|
1378
|
+
const pageStep = Math.max(1, Math.floor(menu.items.length / 2));
|
|
1379
|
+
|
|
1380
|
+
const render = () => {
|
|
1381
|
+
const lines = menuLines(baseArgs, menu, selectedIndex, numberBuffer, pendingG, commandBuffer);
|
|
1382
|
+
if (renderedLineCount > 0) {
|
|
1383
|
+
process.stdout.write(`\x1b[${renderedLineCount}F`);
|
|
1384
|
+
process.stdout.write("\x1b[0J");
|
|
1385
|
+
}
|
|
1386
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
1387
|
+
renderedLineCount = lines.length;
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
const cleanup = () => {
|
|
1391
|
+
process.stdin.off("keypress", onKeypress);
|
|
1392
|
+
process.stdin.setRawMode(false);
|
|
1393
|
+
process.stdin.pause();
|
|
1394
|
+
rl.resume();
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
const resolveSelection = (value) => {
|
|
1398
|
+
cleanup();
|
|
1399
|
+
process.stdout.write("\n");
|
|
1400
|
+
resolve(value);
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
const onKeypress = (str, key) => {
|
|
1404
|
+
if (key?.ctrl && key.name === "c") {
|
|
1405
|
+
cleanup();
|
|
1406
|
+
reject(new Error("Aborted with Ctrl+C"));
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
if (key?.ctrl && key.name === "u") {
|
|
1410
|
+
selectedIndex = Math.max(0, selectedIndex - pageStep);
|
|
1411
|
+
numberBuffer = "";
|
|
1412
|
+
pendingG = false;
|
|
1413
|
+
commandBuffer = "";
|
|
1414
|
+
render();
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
if (key?.ctrl && key.name === "d") {
|
|
1418
|
+
selectedIndex = Math.min(menu.items.length - 1, selectedIndex + pageStep);
|
|
1419
|
+
numberBuffer = "";
|
|
1420
|
+
pendingG = false;
|
|
1421
|
+
commandBuffer = "";
|
|
1422
|
+
render();
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (str !== "g") {
|
|
1426
|
+
pendingG = false;
|
|
1427
|
+
}
|
|
1428
|
+
if (str === "k" && commandBuffer.length > 0) {
|
|
1429
|
+
commandBuffer += str;
|
|
1430
|
+
if (endsWithBackKeyword(commandBuffer)) {
|
|
1431
|
+
resolveSelection("0");
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
render();
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
if (key?.name === "up" || str === "k") {
|
|
1438
|
+
selectedIndex = (selectedIndex - 1 + menu.items.length) % menu.items.length;
|
|
1439
|
+
numberBuffer = "";
|
|
1440
|
+
commandBuffer = "";
|
|
1441
|
+
render();
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (key?.name === "down" || str === "j") {
|
|
1445
|
+
selectedIndex = (selectedIndex + 1) % menu.items.length;
|
|
1446
|
+
numberBuffer = "";
|
|
1447
|
+
commandBuffer = "";
|
|
1448
|
+
render();
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (key?.name === "escape" || key?.name === "left" || str === "h") {
|
|
1452
|
+
if (menu.key !== MAIN_MENU_KEY) {
|
|
1453
|
+
resolveSelection("0");
|
|
1454
|
+
}
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (key?.name === "backspace") {
|
|
1458
|
+
if (numberBuffer.length > 0) {
|
|
1459
|
+
numberBuffer = numberBuffer.slice(0, -1);
|
|
1460
|
+
} else {
|
|
1461
|
+
commandBuffer = commandBuffer.slice(0, -1);
|
|
1462
|
+
}
|
|
1463
|
+
render();
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
1467
|
+
if (numberBuffer.length > 0) {
|
|
1468
|
+
const normalizedChoice = normalizeMenuChoice(numberBuffer);
|
|
1469
|
+
const item = menu.items.find((entry) => entry.value === normalizedChoice);
|
|
1470
|
+
if (item) {
|
|
1471
|
+
resolveSelection(item.value);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
numberBuffer = "";
|
|
1475
|
+
render();
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
if (commandBuffer.length > 0) {
|
|
1479
|
+
if (endsWithBackKeyword(commandBuffer)) {
|
|
1480
|
+
resolveSelection("0");
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
commandBuffer = "";
|
|
1484
|
+
render();
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
resolveSelection(menu.items[selectedIndex].value);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (str === "l") {
|
|
1491
|
+
resolveSelection(menu.items[selectedIndex].value);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (str === "G") {
|
|
1495
|
+
selectedIndex = menu.items.length - 1;
|
|
1496
|
+
numberBuffer = "";
|
|
1497
|
+
commandBuffer = "";
|
|
1498
|
+
render();
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (str === "g") {
|
|
1502
|
+
if (pendingG) {
|
|
1503
|
+
selectedIndex = 0;
|
|
1504
|
+
numberBuffer = "";
|
|
1505
|
+
pendingG = false;
|
|
1506
|
+
commandBuffer = "";
|
|
1507
|
+
} else {
|
|
1508
|
+
pendingG = true;
|
|
1509
|
+
}
|
|
1510
|
+
render();
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
if (/^[0-9]$/.test(str)) {
|
|
1514
|
+
numberBuffer += str;
|
|
1515
|
+
commandBuffer = "";
|
|
1516
|
+
render();
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
if (str && !key?.ctrl && !key?.meta) {
|
|
1520
|
+
const shouldStartCommand =
|
|
1521
|
+
commandBuffer.length > 0 ||
|
|
1522
|
+
str === "." ||
|
|
1523
|
+
/^[bB]$/.test(str) ||
|
|
1524
|
+
str === "返";
|
|
1525
|
+
if (shouldStartCommand) {
|
|
1526
|
+
commandBuffer += str;
|
|
1527
|
+
numberBuffer = "";
|
|
1528
|
+
if (endsWithBackKeyword(commandBuffer)) {
|
|
1529
|
+
resolveSelection("0");
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
render();
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
if (str && ["q", "Q"].includes(str)) {
|
|
1537
|
+
resolveSelection(menu.key === MAIN_MENU_KEY ? "0" : "0");
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
process.stdin.on("keypress", onKeypress);
|
|
1542
|
+
process.stdin.setRawMode(true);
|
|
1543
|
+
process.stdin.resume();
|
|
1544
|
+
render();
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// 在命名资源列表中选择一个项,可附加“手动输入”入口。
|
|
1549
|
+
async function chooseNamedOption(rl, baseArgs, options) {
|
|
1550
|
+
const items = options.names.map((name, index) => ({
|
|
1551
|
+
value: String(index + 1),
|
|
1552
|
+
label: name,
|
|
1553
|
+
hint: options.itemHint ?? "选择这个项目",
|
|
1554
|
+
kind: options.itemKind ?? "secondary",
|
|
1555
|
+
pickedName: name,
|
|
1556
|
+
}));
|
|
1557
|
+
|
|
1558
|
+
if (options.allowManual) {
|
|
1559
|
+
items.push({
|
|
1560
|
+
value: String(items.length + 1),
|
|
1561
|
+
label: options.manualLabel ?? "手动输入",
|
|
1562
|
+
hint: options.manualHint ?? "输入未列出的名称",
|
|
1563
|
+
kind: "assist",
|
|
1564
|
+
manual: true,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
items.push({
|
|
1569
|
+
value: "0",
|
|
1570
|
+
label: options.backLabel ?? "返回上一级",
|
|
1571
|
+
hint: "不进行本次选择",
|
|
1572
|
+
kind: "back",
|
|
1573
|
+
back: true,
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
const menu = {
|
|
1577
|
+
key: options.key,
|
|
1578
|
+
title: options.title,
|
|
1579
|
+
subtitle: options.subtitle,
|
|
1580
|
+
breadcrumb: options.breadcrumb ?? "主菜单",
|
|
1581
|
+
items,
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
const selection = await chooseMenuItem(rl, baseArgs, menu);
|
|
1585
|
+
const item = menu.items.find((entry) => entry.value === selection);
|
|
1586
|
+
if (!item || item.back) {
|
|
1587
|
+
return "";
|
|
1588
|
+
}
|
|
1589
|
+
if (item.manual) {
|
|
1590
|
+
return ask(rl, options.manualPrompt ?? "请输入名称");
|
|
1591
|
+
}
|
|
1592
|
+
return item.pickedName ?? "";
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// 交互式选择受管应用名称,优先展示当前 profile 下已有应用。
|
|
1596
|
+
async function chooseManagedAppName(rl, baseArgs, options = {}) {
|
|
1597
|
+
const appNames = listManagedApps(baseArgs);
|
|
1598
|
+
if (appNames.length === 0) {
|
|
1599
|
+
const typedName = await ask(rl, options.manualPrompt ?? "应用名称");
|
|
1600
|
+
if (!typedName) {
|
|
1601
|
+
return "";
|
|
1602
|
+
}
|
|
1603
|
+
console.log(`提示: 当前还没有受管应用记录,将尝试按默认 config.yml 自动接管“${typedName}”;如果失败,请先执行 add 或 adopt。`);
|
|
1604
|
+
return typedName;
|
|
1605
|
+
}
|
|
1606
|
+
const selectedName = await chooseNamedOption(rl, baseArgs, {
|
|
1607
|
+
key: options.key ?? "managed_app_picker",
|
|
1608
|
+
title: options.title ?? "选择应用",
|
|
1609
|
+
subtitle: options.subtitle ?? "优先从当前 profile 下已有的受管应用中选择。",
|
|
1610
|
+
breadcrumb: options.breadcrumb ?? "主菜单 / 现有应用管理",
|
|
1611
|
+
names: appNames,
|
|
1612
|
+
itemHint: options.itemHint ?? "对这个应用继续操作",
|
|
1613
|
+
itemKind: options.itemKind ?? "secondary",
|
|
1614
|
+
allowManual: options.allowManual ?? true,
|
|
1615
|
+
manualLabel: options.manualLabel ?? "手动输入其他应用名称",
|
|
1616
|
+
manualHint: options.manualHint ?? "输入未列出的应用名称",
|
|
1617
|
+
manualPrompt: options.manualPrompt ?? "应用名称",
|
|
1618
|
+
backLabel: options.backLabel ?? "返回上一级",
|
|
1619
|
+
});
|
|
1620
|
+
if (!selectedName) {
|
|
1621
|
+
return "";
|
|
1622
|
+
}
|
|
1623
|
+
if (appNames.includes(selectedName)) {
|
|
1624
|
+
return selectedName;
|
|
1625
|
+
}
|
|
1626
|
+
console.log(`提示: “${selectedName}”不在当前受管应用列表中。将继续尝试按默认 config.yml 自动接管;如果失败,请先执行 add 或 adopt。`);
|
|
1627
|
+
return selectedName;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// 为 status 选择应用目标,支持“查看全部应用”。
|
|
1631
|
+
async function chooseStatusTarget(rl, baseArgs) {
|
|
1632
|
+
const appNames = listManagedApps(baseArgs);
|
|
1633
|
+
const items = appNames.map((appName, index) => ({
|
|
1634
|
+
value: String(index + 1),
|
|
1635
|
+
label: appName,
|
|
1636
|
+
hint: "只查看这个应用的状态",
|
|
1637
|
+
kind: "secondary",
|
|
1638
|
+
pickedName: appName,
|
|
1639
|
+
}));
|
|
1640
|
+
|
|
1641
|
+
items.push({
|
|
1642
|
+
value: String(items.length + 1),
|
|
1643
|
+
label: "查看全部应用",
|
|
1644
|
+
hint: "执行 status,不限定单个应用",
|
|
1645
|
+
kind: "assist",
|
|
1646
|
+
all: true,
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
items.push({
|
|
1650
|
+
value: String(items.length + 1),
|
|
1651
|
+
label: "手动输入应用名称",
|
|
1652
|
+
hint: "输入未列出的应用名称",
|
|
1653
|
+
kind: "assist",
|
|
1654
|
+
manual: true,
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
items.push({
|
|
1658
|
+
value: "0",
|
|
1659
|
+
label: "返回上一级",
|
|
1660
|
+
hint: "不进行本次选择",
|
|
1661
|
+
kind: "back",
|
|
1662
|
+
back: true,
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
const menu = {
|
|
1666
|
+
key: "status_target_picker",
|
|
1667
|
+
title: "选择状态查看范围",
|
|
1668
|
+
subtitle: "可以查看单个应用,也可以查看全部应用。",
|
|
1669
|
+
breadcrumb: "主菜单 / 现有应用管理 / 查看状态",
|
|
1670
|
+
items,
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1673
|
+
const selection = await chooseMenuItem(rl, baseArgs, menu);
|
|
1674
|
+
const item = menu.items.find((entry) => entry.value === selection);
|
|
1675
|
+
if (!item || item.back) {
|
|
1676
|
+
return { name: "", all: false, cancelled: true };
|
|
1677
|
+
}
|
|
1678
|
+
if (item.all) {
|
|
1679
|
+
return { name: "", all: true, cancelled: false };
|
|
1680
|
+
}
|
|
1681
|
+
if (item.manual) {
|
|
1682
|
+
const typedName = await ask(rl, "应用名称");
|
|
1683
|
+
if (!typedName) {
|
|
1684
|
+
return { name: "", all: false, cancelled: true };
|
|
1685
|
+
}
|
|
1686
|
+
if (!appNames.includes(typedName)) {
|
|
1687
|
+
console.log(`提示: “${typedName}”不在当前受管应用列表中。将继续尝试按默认 config.yml 自动接管;如果失败,请先执行 add 或 adopt。`);
|
|
1688
|
+
}
|
|
1689
|
+
return { name: typedName, all: false, cancelled: false };
|
|
1690
|
+
}
|
|
1691
|
+
return { name: item.pickedName ?? "", all: false, cancelled: false };
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// 交互式选择远端 tunnel 名称,优先展示当前账号的远端列表。
|
|
1695
|
+
async function chooseRemoteTunnelName(rl, baseArgs) {
|
|
1696
|
+
const tunnelNames = listRemoteTunnels(baseArgs);
|
|
1697
|
+
if (tunnelNames.length === 0) {
|
|
1698
|
+
return ask(rl, "已有远端 tunnel 名称");
|
|
1699
|
+
}
|
|
1700
|
+
return chooseNamedOption(rl, baseArgs, {
|
|
1701
|
+
key: "remote_tunnel_picker",
|
|
1702
|
+
title: "选择远端 Tunnel",
|
|
1703
|
+
subtitle: "优先从当前账号下已有的远端 tunnels 中选择。",
|
|
1704
|
+
breadcrumb: "主菜单 / 新建与接管",
|
|
1705
|
+
names: tunnelNames,
|
|
1706
|
+
itemHint: "使用这个远端 tunnel",
|
|
1707
|
+
allowManual: true,
|
|
1708
|
+
manualLabel: "手动输入其他 tunnel 名称",
|
|
1709
|
+
manualHint: "输入未列出的远端 tunnel 名称",
|
|
1710
|
+
manualPrompt: "已有远端 tunnel 名称",
|
|
1711
|
+
backLabel: "返回上一级",
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// 构造 add/adopt 使用的 ingress 参数。
|
|
1716
|
+
function buildIngressArgs(specs) {
|
|
1717
|
+
const args = [];
|
|
1718
|
+
for (const spec of specs) {
|
|
1719
|
+
args.push("--ingress", spec);
|
|
1720
|
+
}
|
|
1721
|
+
return args;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// 根据输入构造 ingress-remove 参数。
|
|
1725
|
+
function buildRemoveArgs(specs) {
|
|
1726
|
+
const args = [];
|
|
1727
|
+
for (const spec of specs) {
|
|
1728
|
+
if (/^[1-9][0-9]*$/.test(spec)) {
|
|
1729
|
+
args.push("--index", spec);
|
|
1730
|
+
} else {
|
|
1731
|
+
args.push("--hostname", spec);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
return args;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// 交互式处理一次菜单动作。
|
|
1738
|
+
async function handleAction(rl, baseArgs, action, context = {}) {
|
|
1739
|
+
let name = context.appName ?? "";
|
|
1740
|
+
let tunnelName = "";
|
|
1741
|
+
let specs = [];
|
|
1742
|
+
let args = [];
|
|
1743
|
+
let follow = false;
|
|
1744
|
+
|
|
1745
|
+
try {
|
|
1746
|
+
switch (action) {
|
|
1747
|
+
case "1":
|
|
1748
|
+
runShell(baseArgs, ["doctor"]);
|
|
1749
|
+
return { keepRunning: true };
|
|
1750
|
+
case "2":
|
|
1751
|
+
runShell(baseArgs, ["list"]);
|
|
1752
|
+
return { keepRunning: true };
|
|
1753
|
+
case "3":
|
|
1754
|
+
runShell(baseArgs, ["tunnels"]);
|
|
1755
|
+
return { keepRunning: true };
|
|
1756
|
+
case "4":
|
|
1757
|
+
if (!name) {
|
|
1758
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1759
|
+
key: "show_app_picker",
|
|
1760
|
+
title: "选择要查看详情的应用",
|
|
1761
|
+
itemHint: "查看这个应用的详情",
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
if (!name) return { keepRunning: true };
|
|
1765
|
+
runShell(baseArgs, ["show", name]);
|
|
1766
|
+
return { keepRunning: true };
|
|
1767
|
+
case "5":
|
|
1768
|
+
{
|
|
1769
|
+
const addState = await runAddWizard(rl, baseArgs);
|
|
1770
|
+
if (!addState) {
|
|
1771
|
+
console.log("已取消创建新 tunnel。");
|
|
1772
|
+
return { keepRunning: true };
|
|
1773
|
+
}
|
|
1774
|
+
args = ["add", addState.name, "--tunnel-name", addState.tunnelName, ...buildIngressArgs(addState.specs)];
|
|
1775
|
+
if (addState.start) args.push("--start");
|
|
1776
|
+
if (addState.activate) args.push("--activate");
|
|
1777
|
+
runShell(baseArgs, args);
|
|
1778
|
+
return { keepRunning: true };
|
|
1779
|
+
}
|
|
1780
|
+
case "6":
|
|
1781
|
+
{
|
|
1782
|
+
const adoptState = await runAdoptWizard(rl, baseArgs);
|
|
1783
|
+
if (!adoptState) {
|
|
1784
|
+
console.log("已取消接管已有 tunnel。");
|
|
1785
|
+
return { keepRunning: true };
|
|
1786
|
+
}
|
|
1787
|
+
args = ["adopt", adoptState.name, "--tunnel-name", adoptState.tunnelName, ...buildIngressArgs(adoptState.specs)];
|
|
1788
|
+
if (adoptState.ensureDns) args.push("--ensure-dns");
|
|
1789
|
+
if (adoptState.activate) args.push("--activate");
|
|
1790
|
+
runShell(baseArgs, args);
|
|
1791
|
+
return { keepRunning: true };
|
|
1792
|
+
}
|
|
1793
|
+
case "7":
|
|
1794
|
+
if (!name) {
|
|
1795
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1796
|
+
key: "modify_app_picker",
|
|
1797
|
+
title: "选择要修改 ingress 的应用",
|
|
1798
|
+
itemHint: "修改这个应用的 ingress 规则",
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
if (!name) return { keepRunning: true };
|
|
1802
|
+
{
|
|
1803
|
+
const modifyState = await runModifyWizard(rl, baseArgs, name);
|
|
1804
|
+
if (!modifyState) {
|
|
1805
|
+
console.log("已取消修改 ingress。");
|
|
1806
|
+
return { keepRunning: true };
|
|
1807
|
+
}
|
|
1808
|
+
args = ["modify", name];
|
|
1809
|
+
for (const spec of modifyState.specs) {
|
|
1810
|
+
args.push("--set", spec);
|
|
1811
|
+
}
|
|
1812
|
+
if (modifyState.noRestart) args.push("--no-restart");
|
|
1813
|
+
runShell(baseArgs, args);
|
|
1814
|
+
return { keepRunning: true };
|
|
1815
|
+
}
|
|
1816
|
+
case "8":
|
|
1817
|
+
if (!name) {
|
|
1818
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1819
|
+
key: "ingress_add_app_picker",
|
|
1820
|
+
title: "选择要新增 ingress 的应用",
|
|
1821
|
+
itemHint: "为这个应用新增 ingress 规则",
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
if (!name) return { keepRunning: true };
|
|
1825
|
+
{
|
|
1826
|
+
const addState = await runIngressAddWizard(rl, baseArgs, name);
|
|
1827
|
+
if (!addState) {
|
|
1828
|
+
console.log("已取消新增 ingress。");
|
|
1829
|
+
return { keepRunning: true };
|
|
1830
|
+
}
|
|
1831
|
+
args = ["ingress-add", name, ...buildIngressArgs(addState.specs)];
|
|
1832
|
+
if (addState.noRestart) args.push("--no-restart");
|
|
1833
|
+
runShell(baseArgs, args);
|
|
1834
|
+
return { keepRunning: true };
|
|
1835
|
+
}
|
|
1836
|
+
case "9":
|
|
1837
|
+
if (!name) {
|
|
1838
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1839
|
+
key: "ingress_remove_app_picker",
|
|
1840
|
+
title: "选择要删除 ingress 的应用",
|
|
1841
|
+
itemHint: "删除这个应用的 ingress 规则",
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
if (!name) return { keepRunning: true };
|
|
1845
|
+
{
|
|
1846
|
+
const removeState = await runIngressRemoveWizard(rl, baseArgs, name);
|
|
1847
|
+
if (!removeState) {
|
|
1848
|
+
console.log("已取消删除 ingress。");
|
|
1849
|
+
return { keepRunning: true };
|
|
1850
|
+
}
|
|
1851
|
+
args = ["ingress-remove", name, ...buildRemoveArgs(removeState.specs)];
|
|
1852
|
+
if (removeState.noRestart) args.push("--no-restart");
|
|
1853
|
+
runShell(baseArgs, args);
|
|
1854
|
+
return { keepRunning: true };
|
|
1855
|
+
}
|
|
1856
|
+
case "10":
|
|
1857
|
+
if (!name) {
|
|
1858
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1859
|
+
key: "start_app_picker",
|
|
1860
|
+
title: "选择要启动的应用",
|
|
1861
|
+
itemHint: "启动这个应用",
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
if (!name) return { keepRunning: true };
|
|
1865
|
+
runShell(baseArgs, ["start", name]);
|
|
1866
|
+
return { keepRunning: true };
|
|
1867
|
+
case "11":
|
|
1868
|
+
if (!name) {
|
|
1869
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1870
|
+
key: "stop_app_picker",
|
|
1871
|
+
title: "选择要停止的应用",
|
|
1872
|
+
itemHint: "停止这个应用",
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
if (!name) return { keepRunning: true };
|
|
1876
|
+
runShell(baseArgs, ["stop", name]);
|
|
1877
|
+
return { keepRunning: true };
|
|
1878
|
+
case "12":
|
|
1879
|
+
if (!name) {
|
|
1880
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1881
|
+
key: "restart_app_picker",
|
|
1882
|
+
title: "选择要重启的应用",
|
|
1883
|
+
itemHint: "重启这个应用",
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
if (!name) return { keepRunning: true };
|
|
1887
|
+
runShell(baseArgs, ["restart", name]);
|
|
1888
|
+
return { keepRunning: true };
|
|
1889
|
+
case "13":
|
|
1890
|
+
if (!name) {
|
|
1891
|
+
const target = await chooseStatusTarget(rl, baseArgs);
|
|
1892
|
+
if (target.cancelled) return { keepRunning: true };
|
|
1893
|
+
if (!target.all) {
|
|
1894
|
+
name = target.name;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
runShell(baseArgs, name ? ["status", name] : ["status"]);
|
|
1898
|
+
return { keepRunning: true };
|
|
1899
|
+
case "14":
|
|
1900
|
+
if (!name) {
|
|
1901
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1902
|
+
key: "logs_app_picker",
|
|
1903
|
+
title: "选择要查看日志的应用",
|
|
1904
|
+
itemHint: "查看这个应用的日志",
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
if (!name) return { keepRunning: true };
|
|
1908
|
+
follow = await askYesNo(rl, "是否持续跟随日志", false);
|
|
1909
|
+
runShell(baseArgs, follow ? ["logs", name, "-f"] : ["logs", name]);
|
|
1910
|
+
return { keepRunning: true };
|
|
1911
|
+
case "15":
|
|
1912
|
+
if (!name) {
|
|
1913
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1914
|
+
key: "activate_app_picker",
|
|
1915
|
+
title: "选择要激活的应用",
|
|
1916
|
+
itemHint: "把这个应用激活为默认配置",
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
if (!name) return { keepRunning: true };
|
|
1920
|
+
runShell(baseArgs, ["activate", name]);
|
|
1921
|
+
return { keepRunning: true };
|
|
1922
|
+
case "16":
|
|
1923
|
+
if (!name) {
|
|
1924
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1925
|
+
key: "delete_app_picker",
|
|
1926
|
+
title: "选择要删除的应用",
|
|
1927
|
+
itemHint: "删除这个受管应用",
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
if (!name) return { keepRunning: true };
|
|
1931
|
+
args = ["delete", name];
|
|
1932
|
+
if (await askYesNo(rl, "是否同时删除远端 tunnel", false)) args.push("--delete-tunnel");
|
|
1933
|
+
runShell(baseArgs, args);
|
|
1934
|
+
return { keepRunning: true, nextMenuKey: "app_picker", clearAppName: true };
|
|
1935
|
+
case "17":
|
|
1936
|
+
args = ["init"];
|
|
1937
|
+
if (await askYesNo(rl, "是否同时执行 login", false)) args.push("--login");
|
|
1938
|
+
runShell(baseArgs, args);
|
|
1939
|
+
return { keepRunning: true };
|
|
1940
|
+
case "18":
|
|
1941
|
+
runShell(baseArgs, ["use"]);
|
|
1942
|
+
return { keepRunning: true };
|
|
1943
|
+
case "19":
|
|
1944
|
+
runShell(baseArgs, ["login"]);
|
|
1945
|
+
return { keepRunning: true };
|
|
1946
|
+
case "20": {
|
|
1947
|
+
const source = await ask(rl, "证书来源路径,留空则使用默认 cert.pem");
|
|
1948
|
+
runShell(baseArgs, source ? ["import-cert", source] : ["import-cert"]);
|
|
1949
|
+
return { keepRunning: true };
|
|
1950
|
+
}
|
|
1951
|
+
case "21": {
|
|
1952
|
+
const raw = await ask(rl, "输入脚本参数,例如 doctor 或 start demo");
|
|
1953
|
+
if (!raw) return { keepRunning: true };
|
|
1954
|
+
try {
|
|
1955
|
+
runShell(baseArgs, splitCommandLine(raw));
|
|
1956
|
+
} catch (error) {
|
|
1957
|
+
console.error(`错误: ${error.message}`);
|
|
1958
|
+
}
|
|
1959
|
+
return { keepRunning: true };
|
|
1960
|
+
}
|
|
1961
|
+
case "22": {
|
|
1962
|
+
if (!name) {
|
|
1963
|
+
name = await chooseManagedAppName(rl, baseArgs, {
|
|
1964
|
+
key: "ingress_list_app_picker",
|
|
1965
|
+
title: "选择要查看 ingress 的应用",
|
|
1966
|
+
itemHint: "查看这个应用的 ingress 列表",
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
if (!name) return { keepRunning: true };
|
|
1970
|
+
runShell(baseArgs, ["ingress-list", name]);
|
|
1971
|
+
return { keepRunning: true };
|
|
1972
|
+
}
|
|
1973
|
+
case "0":
|
|
1974
|
+
case "q":
|
|
1975
|
+
case "quit":
|
|
1976
|
+
case "exit":
|
|
1977
|
+
return { keepRunning: false };
|
|
1978
|
+
default:
|
|
1979
|
+
console.log("无效选项,请重新输入。");
|
|
1980
|
+
return { keepRunning: true };
|
|
1981
|
+
}
|
|
1982
|
+
} catch (error) {
|
|
1983
|
+
if (error instanceof BackToMenuError) {
|
|
1984
|
+
console.log("已取消当前操作,返回主菜单。");
|
|
1985
|
+
return { keepRunning: true, nextMenuKey: MAIN_MENU_KEY, clearAppName: true };
|
|
1986
|
+
}
|
|
1987
|
+
throw error;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// 运行交互式菜单。
|
|
1992
|
+
async function runInteractive(baseArgs) {
|
|
1993
|
+
const rl = createInterface({
|
|
1994
|
+
input: process.stdin,
|
|
1995
|
+
output: process.stdout,
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
try {
|
|
1999
|
+
let keepRunning = true;
|
|
2000
|
+
let currentMenuKey = MAIN_MENU_KEY;
|
|
2001
|
+
let currentAppName = "";
|
|
2002
|
+
while (keepRunning) {
|
|
2003
|
+
const menu = buildMenu(baseArgs, currentMenuKey, currentAppName);
|
|
2004
|
+
const selection = await chooseMenuItem(rl, baseArgs, menu);
|
|
2005
|
+
const item = menu.items.find((entry) => entry.value === selection);
|
|
2006
|
+
|
|
2007
|
+
if (!item) {
|
|
2008
|
+
console.log("无效选项,请重新输入。");
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
if (item.exit) {
|
|
2013
|
+
keepRunning = false;
|
|
2014
|
+
continue;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
if (item.disabled) {
|
|
2018
|
+
console.log("当前没有可选项目。");
|
|
2019
|
+
continue;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
if (item.back) {
|
|
2023
|
+
if (currentMenuKey === "app_detail") {
|
|
2024
|
+
currentMenuKey = "app_picker";
|
|
2025
|
+
currentAppName = "";
|
|
2026
|
+
} else {
|
|
2027
|
+
currentMenuKey = MAIN_MENU_KEY;
|
|
2028
|
+
}
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
if (item.appName) {
|
|
2033
|
+
currentAppName = item.appName;
|
|
2034
|
+
currentMenuKey = "app_detail";
|
|
2035
|
+
continue;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
if (item.next) {
|
|
2039
|
+
currentMenuKey = item.next;
|
|
2040
|
+
continue;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
if (item.action) {
|
|
2044
|
+
const result = await handleAction(rl, baseArgs, item.action, { appName: currentAppName });
|
|
2045
|
+
keepRunning = result.keepRunning;
|
|
2046
|
+
if (result.clearAppName) {
|
|
2047
|
+
currentAppName = "";
|
|
2048
|
+
}
|
|
2049
|
+
if (result.nextMenuKey) {
|
|
2050
|
+
currentMenuKey = result.nextMenuKey;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
} finally {
|
|
2055
|
+
rl.close();
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// 主入口,决定转发模式还是交互模式。
|
|
2060
|
+
async function main() {
|
|
2061
|
+
if (!existsSync(SHELL_SCRIPT)) {
|
|
2062
|
+
fail(`找不到底层脚本: ${SHELL_SCRIPT}`);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
const rawArgs = process.argv.slice(2);
|
|
2066
|
+
const wantsInteractive =
|
|
2067
|
+
rawArgs.length === 0 || rawArgs.some((arg) => INTERACTIVE_FLAGS.has(arg));
|
|
2068
|
+
const baseArgs = rawArgs.filter((arg) => !INTERACTIVE_FLAGS.has(arg));
|
|
2069
|
+
|
|
2070
|
+
if (!wantsInteractive) {
|
|
2071
|
+
runShell([], rawArgs, { exitOnComplete: true });
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
await runInteractive(baseArgs);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
main().catch((error) => {
|
|
2079
|
+
fail(error.message);
|
|
2080
|
+
});
|