maolike-cli 1.0.7 → 2.0.1

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.
@@ -1,35 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RemoteServer = void 0;
4
- exports.createRemoteConnection = createRemoteConnection;
4
+ exports.quoteShellValue = quoteShellValue;
5
5
  const tslib_1 = require("tslib");
6
6
  const node_ssh_1 = require("node-ssh");
7
7
  const chalk_1 = tslib_1.__importDefault(require("chalk"));
8
8
  const ora_1 = tslib_1.__importDefault(require("ora"));
9
- /**
10
- * 远程服务器交互包装类
11
- * 基于 node-ssh 封装,提供带状态提示的指令执行与文件上传功能。
12
- */
9
+ const child_process_1 = require("child_process");
10
+ function quoteShellValue(value) {
11
+ return `'${value.replace(/'/g, `'\\''`)}'`;
12
+ }
13
+ /** 基于 node-ssh 的远程服务器交互包装。 */
13
14
  class RemoteServer {
14
15
  constructor(config) {
15
- this.ssh = new node_ssh_1.NodeSSH();
16
16
  this.config = config;
17
+ this.ssh = new node_ssh_1.NodeSSH();
17
18
  }
18
- /**
19
- * 建立 SSH 连接
20
- * @throws {Error} 连接失败时抛出错误
21
- */
22
19
  connect() {
23
20
  return tslib_1.__awaiter(this, void 0, void 0, function* () {
24
21
  try {
25
- yield this.ssh.connect({
26
- host: this.config.host,
27
- port: this.config.port || 22,
28
- username: this.config.user,
29
- password: this.config.password,
30
- keepaliveInterval: 10000,
31
- });
32
- return true;
22
+ const agentForwardConfig = this.config.agentForward && process.env.SSH_AUTH_SOCK ? { agent: process.env.SSH_AUTH_SOCK, agentForward: true } : {};
23
+ yield this.ssh.connect(Object.assign(Object.assign({ host: this.config.host, port: this.config.port || 22, username: this.config.username, password: this.config.password || undefined, privateKeyPath: this.config.sshPrivateKey || undefined }, agentForwardConfig), { keepaliveInterval: 10000 }));
33
24
  }
34
25
  catch (error) {
35
26
  throw new Error(`SSH 连接失败 [${this.config.host}]: ${error.message}`);
@@ -37,26 +28,73 @@ class RemoteServer {
37
28
  });
38
29
  }
39
30
  /**
40
- * 执行远程 Shell 命令,并同步显示 ora 进度
41
- * @param command 命令字符串
42
- * @param cwd 远程执行目录
43
- * @param taskName 任务描述文案
31
+ * 在当前终端打开远程交互式 Shell
32
+ *
33
+ * 连接配置仍由 node-ssh 管理,密码和私钥不会经过命令行参数或日志。
44
34
  */
35
+ openInteractiveShell() {
36
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
37
+ var _a, _b;
38
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
39
+ throw new Error("`ml connect` 需要在交互式终端中运行");
40
+ }
41
+ yield this.connect();
42
+ const shell = yield this.ssh.requestShell({
43
+ term: process.env.TERM || "xterm-256color",
44
+ cols: process.stdout.columns || 80,
45
+ rows: process.stdout.rows || 24,
46
+ });
47
+ const stdinWasPaused = process.stdin.isPaused();
48
+ const stdinWasRaw = process.stdin.isRaw;
49
+ const resize = () => {
50
+ shell.setWindow(process.stdout.rows || 24, process.stdout.columns || 80, 0, 0);
51
+ };
52
+ (_b = (_a = process.stdin).setRawMode) === null || _b === void 0 ? void 0 : _b.call(_a, true);
53
+ process.stdin.resume();
54
+ process.stdin.pipe(shell);
55
+ shell.pipe(process.stdout);
56
+ shell.stderr.pipe(process.stderr);
57
+ process.stdout.on("resize", resize);
58
+ try {
59
+ yield new Promise((resolve, reject) => {
60
+ shell.once("close", resolve);
61
+ shell.once("error", reject);
62
+ });
63
+ }
64
+ finally {
65
+ process.stdout.removeListener("resize", resize);
66
+ process.stdin.unpipe(shell);
67
+ shell.unpipe(process.stdout);
68
+ shell.stderr.unpipe(process.stderr);
69
+ if (process.stdin.setRawMode)
70
+ process.stdin.setRawMode(stdinWasRaw !== null && stdinWasRaw !== void 0 ? stdinWasRaw : false);
71
+ if (stdinWasPaused)
72
+ process.stdin.pause();
73
+ shell.destroy();
74
+ }
75
+ });
76
+ }
45
77
  exec(command_1, cwd_1) {
46
- return tslib_1.__awaiter(this, arguments, void 0, function* (command, cwd, taskName = "Task") {
78
+ return tslib_1.__awaiter(this, arguments, void 0, function* (command, cwd, taskName = "Task", options = {}) {
79
+ var _a;
47
80
  const spinner = (0, ora_1.default)(`${taskName} (Remote: ${this.config.host})`).start();
48
81
  try {
49
- const cmd = cwd ? `cd ${cwd} && ${command}` : command;
50
- const result = yield this.ssh.execCommand(cmd);
82
+ const remoteCommand = cwd ? `cd ${quoteShellValue(cwd)} && ${command}` : command;
83
+ let result = yield this.ssh.execCommand(remoteCommand);
84
+ let stderr = result.stderr || result.stdout || "";
85
+ if (result.code !== 0 && options.retryAfterAgentReload && this.isGitAuthError(stderr) && (yield this.reloadSshAgentIfEmpty())) {
86
+ spinner.text = `${taskName}: SSH 密钥已重载,正在重试...`;
87
+ result = yield this.ssh.execCommand(remoteCommand);
88
+ stderr = result.stderr || result.stdout || "";
89
+ }
51
90
  if (result.code !== 0) {
52
91
  spinner.fail(`${taskName} 失败`);
53
- console.error(chalk_1.default.red(`\n[Remote Stderr]:\n${result.stderr || result.stdout}`));
54
- throw new Error(`远程命令执行出错 (Exit Code: ${result.code})`);
92
+ console.error(chalk_1.default.red(`\n[Remote Stderr]:\n${stderr}`));
93
+ throw new Error(this.formatExecError(stderr, (_a = result.code) !== null && _a !== void 0 ? _a : -1));
55
94
  }
56
95
  spinner.succeed(`${taskName} 完成`);
57
- if (result.stdout) {
96
+ if (result.stdout)
58
97
  console.log(chalk_1.default.gray(`[Remote Output]:\n${result.stdout.trim()}`));
59
- }
60
98
  return result.stdout;
61
99
  }
62
100
  catch (error) {
@@ -66,63 +104,66 @@ class RemoteServer {
66
104
  }
67
105
  });
68
106
  }
69
- /**
70
- * 上传单个文件到服务器
71
- * @param localPath 本地绝对路径
72
- * @param remotePath 远程绝对目标路径
73
- */
74
107
  uploadFile(localPath, remotePath) {
75
108
  return tslib_1.__awaiter(this, void 0, void 0, function* () {
76
109
  return this.ssh.putFile(localPath, remotePath);
77
110
  });
78
111
  }
79
- /**
80
- * 递归上传整个目录
81
- * @param localPath 本地源目录
82
- * @param remotePath 远程目标目录
83
- */
84
112
  uploadDirectory(localPath, remotePath) {
85
113
  return tslib_1.__awaiter(this, void 0, void 0, function* () {
86
- return this.ssh.putDirectory(localPath, remotePath, {
87
- recursive: true,
88
- concurrency: 10,
89
- });
114
+ return this.ssh.putDirectory(localPath, remotePath, { recursive: true, concurrency: 10 });
90
115
  });
91
116
  }
92
- /**
93
- * 生成带身份验证信息的 Git Pull 命令
94
- * 优先从 SSH 配置中读取凭据并注入到 git-c 辅助程序中
95
- * @returns 拼接好的命令行字符串
96
- */
97
117
  getGitPullCommand() {
98
- const { gitUser, gitPassword } = this.config;
99
- if (gitUser && gitPassword) {
100
- // 使用 git -c 注入临时凭据辅助程序,避免修改远程 URL 或全局配置
101
- return `git -c credential.helper='!f() { echo "username=${gitUser}"; echo "password=${gitPassword}"; }; f' pull`;
102
- }
103
- return "git pull";
118
+ var _a;
119
+ const username = (_a = this.config.gitUsername) === null || _a === void 0 ? void 0 : _a.trim();
120
+ const password = this.config.gitPassword || "";
121
+ const gitPrefix = `GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND=${quoteShellValue("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new")}`;
122
+ if (!username)
123
+ return `${gitPrefix} git pull`;
124
+ const helper = `!f() { echo "username=${username}"; echo "password=${password}"; }; f`;
125
+ return `${gitPrefix} git -c credential.helper=${quoteShellValue(helper)} pull`;
104
126
  }
105
- /**
106
- * 断开 SSH 链接释放资源
107
- */
108
127
  disconnect() {
109
128
  this.ssh.dispose();
110
129
  }
130
+ reloadSshAgentIfEmpty() {
131
+ return tslib_1.__awaiter(this, void 0, void 0, function* () {
132
+ if (!this.config.agentForward || !process.env.SSH_AUTH_SOCK)
133
+ return false;
134
+ if ((yield runSshAdd(["-l"], "ignore")) !== 1)
135
+ return false;
136
+ console.log(chalk_1.default.yellow("本机 SSH Agent 没有已加载的密钥,正在自动重载..."));
137
+ const args = process.platform === "darwin" ? ["--apple-load-keychain"] : [];
138
+ yield runSshAdd(args, "inherit");
139
+ if ((yield runSshAdd(["-l"], "ignore")) === 0) {
140
+ console.log(chalk_1.default.green("SSH Agent 密钥重载成功,将重新尝试远程 Git Pull。"));
141
+ return true;
142
+ }
143
+ console.warn(chalk_1.default.yellow("SSH Agent 自动重载失败或重载后仍没有密钥,保留原始 Git 错误。"));
144
+ return false;
145
+ });
146
+ }
147
+ isGitAuthError(stderr) {
148
+ return /Permission denied \(publickey\)/i.test(stderr) || /Could not read from remote repository/i.test(stderr);
149
+ }
150
+ formatExecError(stderr, code) {
151
+ if (this.isGitAuthError(stderr)) {
152
+ const hint = this.config.agentForward
153
+ ? process.env.SSH_AUTH_SOCK
154
+ ? "请确认本机 SSH Agent 已加载对应私钥,并且仓库成员权限正常。"
155
+ : "已配置 agentForward,但当前终端没有 SSH_AUTH_SOCK。"
156
+ : "可开启服务器的 agentForward,或在服务器上配置 deploy key。";
157
+ return `远程 Git 认证失败 (SSH publickey)。${hint}`;
158
+ }
159
+ return `远程命令执行出错 (Exit Code: ${code})`;
160
+ }
111
161
  }
112
162
  exports.RemoteServer = RemoteServer;
113
- /**
114
- * 工厂函数:合并全局与项目级配置,返回可用的连接实例
115
- * @param globalSsh 全局 SSH 配置
116
- * @param itemSsh 项目特定的覆盖配置
117
- */
118
- function createRemoteConnection(globalSsh, itemSsh) {
119
- return tslib_1.__awaiter(this, void 0, void 0, function* () {
120
- const merged = Object.assign(Object.assign(Object.assign({}, globalSsh), itemSsh), { host: (itemSsh === null || itemSsh === void 0 ? void 0 : itemSsh.host) || (globalSsh === null || globalSsh === void 0 ? void 0 : globalSsh.host) || "", port: (itemSsh === null || itemSsh === void 0 ? void 0 : itemSsh.port) || (globalSsh === null || globalSsh === void 0 ? void 0 : globalSsh.port) || 22, user: (itemSsh === null || itemSsh === void 0 ? void 0 : itemSsh.user) || (globalSsh === null || globalSsh === void 0 ? void 0 : globalSsh.user) || "" });
121
- if (!merged.host || !merged.user) {
122
- throw new Error("无效的 SSH 配置: 缺少 host 或 user");
123
- }
124
- const server = new RemoteServer(merged);
125
- yield server.connect();
126
- return server;
163
+ function runSshAdd(args, stdio) {
164
+ return new Promise((resolve) => {
165
+ const child = (0, child_process_1.spawn)("ssh-add", args, { stdio });
166
+ child.once("error", () => resolve(-1));
167
+ child.once("close", (code) => resolve(code !== null && code !== void 0 ? code : -1));
127
168
  });
128
169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maolike-cli",
3
- "version": "1.0.7",
3
+ "version": "2.0.1",
4
4
  "description": "自动化构建、测试同步与远程部署的 CLI 工具",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
@@ -1,182 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- const tslib_1 = require("tslib");
4
- const BaseCommand_1 = tslib_1.__importDefault(require("../../utils/BaseCommand"));
5
- const ConfigManager_1 = require("../../services/ConfigManager");
6
- const InitWizard_1 = tslib_1.__importDefault(require("../../ui/InitWizard"));
7
- const Scanner_1 = require("../../utils/Scanner");
8
- const GitProcessor_1 = tslib_1.__importDefault(require("../../core/GitProcessor"));
9
- const PipelineExecutor_1 = tslib_1.__importDefault(require("../../core/PipelineExecutor"));
10
- const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
11
- const chalk_1 = tslib_1.__importDefault(require("chalk"));
12
- const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
13
- const path_1 = tslib_1.__importDefault(require("path"));
14
- /**
15
- * 仪表盘菜单命令
16
- * 提供可视化交互界面,集成初始化、扫描、配置运行等多种功能
17
- */
18
- class MenuCommand extends BaseCommand_1.default {
19
- init() {
20
- this.program.command("menu").description("打开交互式操作菜单").action(this.action.bind(this));
21
- }
22
- action() {
23
- return tslib_1.__awaiter(this, void 0, void 0, function* () {
24
- while (true) {
25
- // 每次循环重新读取配置状态,确保 init 后能刷新菜单项
26
- const config = ConfigManager_1.ConfigManager.config;
27
- const hasConfig = ConfigManager_1.ConfigManager.existsOnDisk();
28
- const choices = [
29
- { name: "📂 扫描本地目录 (Scan Local)", value: "scan" },
30
- { name: "✨ 初始化配置 (Init)", value: "init" },
31
- ];
32
- if (hasConfig) {
33
- choices.unshift({
34
- name: "🚀 从配置已保存项目运行 (Run from Config)",
35
- value: "run_config",
36
- });
37
- }
38
- choices.push(new inquirer_1.default.Separator());
39
- choices.push({ name: "🚪 退出 (Exit)", value: "exit" });
40
- console.log(""); // 增加一点空行间距
41
- const { mode } = yield inquirer_1.default.prompt([
42
- {
43
- type: "list",
44
- name: "mode",
45
- message: "请选择操作模式:",
46
- choices,
47
- pageSize: 10,
48
- },
49
- ]);
50
- if (mode === "exit") {
51
- console.log(chalk_1.default.gray("👋 再见!"));
52
- process.exit(0);
53
- }
54
- try {
55
- yield this.handleMenuSelection(mode, config);
56
- // 操作完成后暂停一下可能更好?或者直接换行
57
- console.log(chalk_1.default.dim("\n----------------------------------------\n"));
58
- }
59
- catch (e) {
60
- console.error(chalk_1.default.red(`\n❌ 操作执行出错: ${e.message}\n`));
61
- }
62
- }
63
- });
64
- }
65
- /**
66
- * 处理主菜单项的选择分发
67
- * @param mode 选择的模式标识
68
- * @param config 当前全局配置对象
69
- * @private
70
- */
71
- handleMenuSelection(mode, config) {
72
- return tslib_1.__awaiter(this, void 0, void 0, function* () {
73
- if (mode === "init") {
74
- yield new InitWizard_1.default().run();
75
- }
76
- else if (mode === "run_config") {
77
- yield this.handleRunConfig(config);
78
- }
79
- else {
80
- yield this.handleScanMode();
81
- }
82
- });
83
- }
84
- /**
85
- * 处理“从配置运行”模式
86
- * 列出所有已保存的项目并开启流水线
87
- * @param config 全局配置对象
88
- * @private
89
- */
90
- handleRunConfig(config) {
91
- return tslib_1.__awaiter(this, void 0, void 0, function* () {
92
- if (!config || !config["git-list"] || config["git-list"].length === 0) {
93
- console.log(chalk_1.default.yellow("暂无配置项目"));
94
- return;
95
- }
96
- const { selected } = yield inquirer_1.default.prompt([
97
- {
98
- type: "list",
99
- name: "selected",
100
- message: "选择要在测试环境更新的项目:",
101
- choices: config["git-list"].map((i) => ({
102
- name: i.name,
103
- value: i.name,
104
- })),
105
- },
106
- ]);
107
- console.log(chalk_1.default.blue(`开始同步 ${selected}...`));
108
- yield this.runPipeline(selected);
109
- });
110
- }
111
- /**
112
- * 处理“扫描本地模式”
113
- * 扫描指定父目录下的 Git 仓库并执行自动化同步(按需读取局部配置)
114
- * @private
115
- */
116
- handleScanMode() {
117
- return tslib_1.__awaiter(this, void 0, void 0, function* () {
118
- const { targetPath } = yield inquirer_1.default.prompt([
119
- {
120
- type: "input",
121
- name: "targetPath",
122
- message: "📂 请拖入或输入包含 Git 项目的父文件夹路径:",
123
- filter: (i) => i.trim(),
124
- },
125
- ]);
126
- const repos = yield (0, Scanner_1.scanForGitRepos)(targetPath);
127
- if (repos.length === 0) {
128
- console.log(chalk_1.default.yellow("未找到 Git 仓库"));
129
- return;
130
- }
131
- const { selectedRepos } = yield inquirer_1.default.prompt([
132
- {
133
- type: "checkbox",
134
- name: "selectedRepos",
135
- message: "请勾选要处理的项目:",
136
- choices: repos.map((r) => ({ name: r.name, value: r })),
137
- },
138
- ]);
139
- for (const repo of selectedRepos) {
140
- const localConfigPath = path_1.default.join(repo.path, ".maolike", "config.json");
141
- if (fs_extra_1.default.existsSync(localConfigPath)) {
142
- try {
143
- const localConfig = yield fs_extra_1.default.readJson(localConfigPath);
144
- yield new PipelineExecutor_1.default(repo.path, repo.name, localConfig).run();
145
- }
146
- catch (e) {
147
- yield new GitProcessor_1.default(repo.path, repo.name).execute();
148
- }
149
- }
150
- else {
151
- yield new GitProcessor_1.default(repo.path, repo.name).execute();
152
- }
153
- }
154
- });
155
- }
156
- /**
157
- * 为指定项目启动流水线执行器
158
- * @param name 项目名称
159
- * @private
160
- */
161
- runPipeline(name) {
162
- return tslib_1.__awaiter(this, void 0, void 0, function* () {
163
- if (!ConfigManager_1.ConfigManager.existsOnDisk()) {
164
- console.log(chalk_1.default.red("请先 init"));
165
- return;
166
- }
167
- const config = ConfigManager_1.ConfigManager.config;
168
- const task = config["git-list"].find((item) => item.name === name);
169
- if (!task) {
170
- console.log(chalk_1.default.red(`未找到项目: ${name}`));
171
- return;
172
- }
173
- console.log(chalk_1.default.magenta(`🚀 正在启动 [${name}] 测试环境同步流程...`));
174
- yield new PipelineExecutor_1.default(task.path, name, {
175
- version: "2.0",
176
- ssh: config.ssh,
177
- "git-list": [task],
178
- }).run();
179
- });
180
- }
181
- }
182
- exports.default = MenuCommand;