@mindbase/deploy 1.0.0 → 1.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.
@@ -0,0 +1,406 @@
1
+ import * as clack from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import { resolve } from 'path';
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { noteBox } from '@mindbase/cli-ui';
6
+ import { getConfig, saveProjectConfig } from '../config/manager.js';
7
+ import type { Step, StepType, ProjectConfig } from '../config/schema.js';
8
+ import { connectSftp } from '../ssh/client.js';
9
+
10
+ /** 取消处理 */
11
+ function handleCancel<T>(result: T | symbol): T {
12
+ if (typeof result === 'symbol' && clack.isCancel(result)) {
13
+ clack.cancel('操作已取消');
14
+ process.exit(0);
15
+ }
16
+ return result as T;
17
+ }
18
+
19
+ /** 格式化单个步骤为摘要行 */
20
+ function formatStepSummary(step: Step): string {
21
+ switch (step.type) {
22
+ case 'log':
23
+ return `log: ${step.message}`;
24
+ case 'local':
25
+ return step.cwd
26
+ ? `local: ${step.cmd} (cwd: ${step.cwd})`
27
+ : `local: ${step.cmd}`;
28
+ case 'remote':
29
+ return `remote: ${step.cmd}`;
30
+ case 'uploadDir':
31
+ return `uploadDir: ${step.local} → ${step.remote}`;
32
+ case 'uploadFile':
33
+ return `uploadFile: ${step.local} → ${step.remote}`;
34
+ case 'downloadDir':
35
+ return `downloadDir: ${step.remote} → ${step.local}`;
36
+ case 'downloadFile':
37
+ return `downloadFile: ${step.remote} → ${step.local}`;
38
+ }
39
+ }
40
+
41
+ /** 展示当前所有已配置步骤 */
42
+ function showStepsSummary(steps: Step[]): void {
43
+ const lines = steps.map((s, i) => `${i + 1}. ${formatStepSummary(s)}`).join('\n');
44
+ noteBox(lines, `已配置步骤 (${steps.length})`);
45
+ }
46
+
47
+ /** 可用变量参考文本 */
48
+ const VARS_REFERENCE = [
49
+ '可用变量:',
50
+ ' ${localDir} 项目本地目录',
51
+ ' ${remoteDir} 项目远程目录',
52
+ ' ${projectName} 项目名称',
53
+ ].join('\n');
54
+
55
+ /** 步骤类型示例 */
56
+ const STEP_EXAMPLES: Record<StepType, string> = {
57
+ log: [
58
+ '示例:',
59
+ ' 开始发布...',
60
+ ' 编译完成',
61
+ ].join('\n'),
62
+ local: [
63
+ VARS_REFERENCE,
64
+ '',
65
+ '示例:',
66
+ ' npm run build',
67
+ ' pnpm install',
68
+ '',
69
+ 'cwd: 工作目录,留空使用默认,可用 ${localDir}',
70
+ ].join('\n'),
71
+ remote: [
72
+ VARS_REFERENCE,
73
+ '',
74
+ '示例:',
75
+ ' cd ${remoteDir} && rm -rf ./*',
76
+ ' mkdir -p ${remoteDir}src',
77
+ ' pm2 restart myapp',
78
+ ].join('\n'),
79
+ uploadDir: [
80
+ VARS_REFERENCE,
81
+ '',
82
+ '示例:',
83
+ ' 本地: ${localDir}/dist',
84
+ ' 远端: ${remoteDir}',
85
+ ].join('\n'),
86
+ uploadFile: [
87
+ VARS_REFERENCE,
88
+ '',
89
+ '示例:',
90
+ ' 本地: ${localDir}/package.json',
91
+ ' 远端: ${remoteDir}package.json',
92
+ ].join('\n'),
93
+ downloadDir: [
94
+ VARS_REFERENCE,
95
+ '',
96
+ '示例:',
97
+ ' 远端: ${remoteDir}backup/',
98
+ ' 本地: ${localDir}/backup',
99
+ ].join('\n'),
100
+ downloadFile: [
101
+ VARS_REFERENCE,
102
+ '',
103
+ '示例:',
104
+ ' 远端: ${remoteDir}config.json',
105
+ ' 本地: ${localDir}/config.json.bak',
106
+ ].join('\n'),
107
+ };
108
+
109
+ /** 步骤类型选项 */
110
+ const STEP_TYPE_OPTIONS = [
111
+ { value: 'log' as StepType, label: '日志输出', hint: '输出信息' },
112
+ { value: 'local' as StepType, label: '本地命令', hint: '在本地执行 shell 命令' },
113
+ { value: 'remote' as StepType, label: '远程命令', hint: '在服务器执行 shell 命令' },
114
+ { value: 'uploadDir' as StepType, label: '上传目录', hint: '上传整个目录到服务器' },
115
+ { value: 'uploadFile' as StepType, label: '上传文件', hint: '上传单个文件到服务器' },
116
+ { value: 'downloadDir' as StepType, label: '下载目录(备份)', hint: '从服务器下载整个目录' },
117
+ { value: 'downloadFile' as StepType, label: '下载文件(备份)', hint: '从服务器下载单个文件' },
118
+ ];
119
+
120
+ /**
121
+ * 读取 package.json 的 name 字段作为默认项目名
122
+ */
123
+ function getDefaultProjectName(cwd: string): string {
124
+ const pkgPath = resolve(cwd, 'package.json');
125
+ if (existsSync(pkgPath)) {
126
+ try {
127
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
128
+ if (pkg.name) return pkg.name;
129
+ } catch { /* 忽略 */ }
130
+ }
131
+ return '';
132
+ }
133
+
134
+ /**
135
+ * 通过 SFTP 浏览远端目录
136
+ */
137
+ async function browseRemoteDir(sftp: import('ssh2-sftp-client'), currentPath: string): Promise<string> {
138
+ while (true) {
139
+ let entries: import('ssh2-sftp-client').FileInfo[];
140
+ try {
141
+ entries = await sftp.list(currentPath);
142
+ } catch {
143
+ // 目录不存在,直接返回手动输入的路径
144
+ return currentPath;
145
+ }
146
+
147
+ const dirOptions = entries
148
+ .filter((e) => e.type === 'd')
149
+ .map((e) => ({ value: e.name, label: `📁 ${e.name}` }));
150
+
151
+ const choices = [
152
+ { value: '__confirm__', label: pc.green(`✓ 使用当前目录: ${currentPath}`) },
153
+ { value: '__manual__', label: pc.yellow('✎ 手动输入路径') },
154
+ ...dirOptions,
155
+ ];
156
+
157
+ const selected = handleCancel<string>(
158
+ await clack.select({
159
+ message: `浏览远端目录: ${currentPath}`,
160
+ options: choices,
161
+ })
162
+ );
163
+
164
+ if (selected === '__confirm__') {
165
+ return currentPath;
166
+ }
167
+
168
+ if (selected === '__manual__') {
169
+ const input = handleCancel<string>(
170
+ await clack.text({
171
+ message: '输入远端目录路径',
172
+ initialValue: currentPath,
173
+ })
174
+ );
175
+ currentPath = input;
176
+ continue;
177
+ }
178
+
179
+ // 进入子目录
180
+ currentPath = currentPath.endsWith('/') ? `${currentPath}${selected}` : `${currentPath}/${selected}`;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * 收集单个步骤
186
+ */
187
+ async function collectStep(): Promise<Step | null> {
188
+ const stepType = handleCancel<StepType>(
189
+ await clack.select({
190
+ message: '选择步骤类型',
191
+ options: STEP_TYPE_OPTIONS,
192
+ })
193
+ );
194
+
195
+ noteBox(STEP_EXAMPLES[stepType], '参考(可直接复制变量)');
196
+
197
+ switch (stepType) {
198
+ case 'log': {
199
+ const message = handleCancel<string>(
200
+ await clack.text({ message: '日志信息' })
201
+ );
202
+ return { type: 'log', message };
203
+ }
204
+ case 'local': {
205
+ const cmd = handleCancel<string>(
206
+ await clack.text({ message: '命令' })
207
+ );
208
+ const cwd = handleCancel<string>(
209
+ await clack.text({
210
+ message: '工作目录(可选,留空使用默认)',
211
+ placeholder: '${localDir}',
212
+ })
213
+ );
214
+ const step: Step = { type: 'local', cmd };
215
+ if (cwd) step.cwd = cwd;
216
+ return step;
217
+ }
218
+ case 'remote': {
219
+ const cmd = handleCancel<string>(
220
+ await clack.text({ message: '远程命令' })
221
+ );
222
+ return { type: 'remote', cmd };
223
+ }
224
+ case 'uploadDir': {
225
+ const local = handleCancel<string>(
226
+ await clack.text({ message: '本地目录', placeholder: '${localDir}/dist' })
227
+ );
228
+ const remote = handleCancel<string>(
229
+ await clack.text({ message: '远端目录', placeholder: '${remoteDir}' })
230
+ );
231
+ return { type: 'uploadDir', local, remote };
232
+ }
233
+ case 'uploadFile': {
234
+ const local = handleCancel<string>(
235
+ await clack.text({ message: '本地文件路径' })
236
+ );
237
+ const remote = handleCancel<string>(
238
+ await clack.text({ message: '远端文件路径' })
239
+ );
240
+ return { type: 'uploadFile', local, remote };
241
+ }
242
+ case 'downloadDir': {
243
+ const remote = handleCancel<string>(
244
+ await clack.text({ message: '远端目录' })
245
+ );
246
+ const local = handleCancel<string>(
247
+ await clack.text({ message: '本地保存目录' })
248
+ );
249
+ return { type: 'downloadDir', remote, local };
250
+ }
251
+ case 'downloadFile': {
252
+ const remote = handleCancel<string>(
253
+ await clack.text({ message: '远端文件路径' })
254
+ );
255
+ const local = handleCancel<string>(
256
+ await clack.text({ message: '本地保存路径' })
257
+ );
258
+ return { type: 'downloadFile', remote, local };
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * 运行 deploy init 主流程
265
+ * @param targetPath 项目目录(缺省 cwd),生成的 deploy.config.json 写入此目录
266
+ */
267
+ export async function runInit(targetPath?: string): Promise<void> {
268
+ clack.intro('deploy init');
269
+
270
+ const cwd = resolve(targetPath ?? '.');
271
+
272
+ const deployConfig = getConfig();
273
+
274
+ // 1. 项目名称
275
+ const defaultName = getDefaultProjectName(cwd);
276
+ const name = handleCancel<string>(
277
+ await clack.text({
278
+ message: '项目显示名称',
279
+ initialValue: defaultName,
280
+ })
281
+ );
282
+
283
+ // 2. 检查是否有可用服务器
284
+ const serverIds = Object.keys(deployConfig.servers);
285
+ if (serverIds.length === 0) {
286
+ clack.log.error('没有已配置的服务器,请先运行 deploy server add 添加服务器');
287
+ clack.outro('请先配置服务器');
288
+ return;
289
+ }
290
+
291
+ // 3. 选择目标服务器
292
+ const serverId = handleCancel<string>(
293
+ await clack.select({
294
+ message: '选择目标服务器',
295
+ options: serverIds.map((id) => ({
296
+ value: id,
297
+ label: `${id} (${deployConfig.servers[id].host})`,
298
+ })),
299
+ })
300
+ );
301
+
302
+ // 4. 本地目录(用相对路径 ./,便于跨设备/入库)
303
+ const localDir = handleCancel<string>(
304
+ await clack.text({
305
+ message: '本地目录(相对 deploy.config.json,默认 ./)',
306
+ initialValue: './',
307
+ })
308
+ );
309
+
310
+ // 5. 远端目录
311
+ const spinner = clack.spinner();
312
+ spinner.start('连接服务器...');
313
+ let sftp: import('ssh2-sftp-client');
314
+ try {
315
+ sftp = await connectSftp('init', deployConfig.servers[serverId]);
316
+ spinner.stop('已连接');
317
+ } catch (err) {
318
+ spinner.stop('连接失败');
319
+ clack.log.error(`无法连接服务器: ${err}`);
320
+ clack.outro('连接失败');
321
+ return;
322
+ }
323
+
324
+ let remoteDir: string;
325
+ try {
326
+ const browseChoice = handleCancel<'browse' | 'manual'>(
327
+ await clack.select({
328
+ message: '远端目录设置方式',
329
+ options: [
330
+ { value: 'browse', label: '浏览服务器目录' },
331
+ { value: 'manual', label: '手动输入路径' },
332
+ ],
333
+ })
334
+ );
335
+
336
+ if (browseChoice === 'browse') {
337
+ remoteDir = await browseRemoteDir(sftp, '/');
338
+ } else {
339
+ remoteDir = handleCancel<string>(
340
+ await clack.text({
341
+ message: '远端目录路径',
342
+ placeholder: '/appliction/my-app/',
343
+ })
344
+ );
345
+ // 确保目录存在(循环创建)
346
+ const confirmMkdir = handleCancel<boolean>(
347
+ await clack.confirm({ message: `是否自动创建目录 ${remoteDir}(如果不存在)?` })
348
+ );
349
+ if (confirmMkdir) {
350
+ await sftp.mkdir(remoteDir, true);
351
+ clack.log.success(`目录已创建: ${remoteDir}`);
352
+ }
353
+ }
354
+ } finally {
355
+ await sftp.end();
356
+ }
357
+
358
+ // 6. 收集步骤
359
+ const steps: Step[] = [];
360
+ clack.log.info('开始配置部署步骤(每个步骤会展示参考和可用变量)');
361
+
362
+ while (true) {
363
+ if (steps.length > 0) {
364
+ const action = handleCancel<'add' | 'done'>(
365
+ await clack.select({
366
+ message: `已配置 ${steps.length} 个步骤`,
367
+ options: [
368
+ { value: 'add', label: '添加步骤' },
369
+ { value: 'done', label: '完成配置' },
370
+ ],
371
+ })
372
+ );
373
+ if (action === 'done') break;
374
+ }
375
+
376
+ const step = await collectStep();
377
+ if (step) {
378
+ steps.push(step);
379
+ showStepsSummary(steps);
380
+ }
381
+ }
382
+
383
+ // 7. 预览配置
384
+ const projectConfig: ProjectConfig = {
385
+ name,
386
+ localDir,
387
+ server: serverId,
388
+ remoteDir,
389
+ steps,
390
+ };
391
+
392
+ noteBox(JSON.stringify(projectConfig, null, 2), '项目配置预览');
393
+
394
+ const confirmSave = handleCancel<boolean>(
395
+ await clack.confirm({ message: '确认保存此配置?' })
396
+ );
397
+
398
+ if (confirmSave) {
399
+ saveProjectConfig(projectConfig, cwd);
400
+ clack.log.success(`配置已写入 ${resolve(cwd, 'deploy.config.json')}`);
401
+ } else {
402
+ clack.log.info('已取消保存');
403
+ }
404
+
405
+ clack.outro('完成');
406
+ }
@@ -0,0 +1,28 @@
1
+ import type { ProjectConfig } from '../config/schema.js';
2
+
3
+ /**
4
+ * 内置变量名
5
+ */
6
+ const BUILT_IN_VARS = ['localDir', 'remoteDir', 'projectName'] as const;
7
+
8
+ /**
9
+ * 替换字符串中的 ${varName} 占位符
10
+ */
11
+ export function resolveVariables(template: string, project: ProjectConfig): string {
12
+ const vars: Record<string, string> = {
13
+ localDir: project.localDir,
14
+ remoteDir: project.remoteDir,
15
+ projectName: project.name,
16
+ };
17
+
18
+ return template.replace(/\$\{(\w+)\}/g, (match, key: string) => {
19
+ return vars[key] !== undefined ? vars[key] : match;
20
+ });
21
+ }
22
+
23
+ /**
24
+ * 获取可用变量列表(用于提示)
25
+ */
26
+ export function getAvailableVariables(): string[] {
27
+ return [...BUILT_IN_VARS];
28
+ }
@@ -0,0 +1,88 @@
1
+ import type SftpClient from 'ssh2-sftp-client';
2
+ import spawn from 'cross-spawn';
3
+ import type { Step, ProjectConfig } from '../config/schema.js';
4
+ import { resolveVariables } from './parser.js';
5
+ import { execRemote } from '../ssh/executor.js';
6
+
7
+ /**
8
+ * 在本地执行命令
9
+ */
10
+ function runLocal(cmd: string, cwd?: string): Promise<void> {
11
+ return new Promise((resolve, reject) => {
12
+ const cp = spawn(cmd, [], {
13
+ cwd,
14
+ detached: false,
15
+ shell: true,
16
+ });
17
+ if (cp.stdout) cp.stdout.pipe(process.stdout);
18
+ if (cp.stderr) cp.stderr.pipe(process.stderr);
19
+ cp.on('close', (code) => {
20
+ if (code === 0) {
21
+ resolve();
22
+ } else {
23
+ reject(new Error(`本地命令执行失败 (code=${code}): ${cmd}`));
24
+ }
25
+ });
26
+ cp.on('error', (err) => {
27
+ reject(err);
28
+ });
29
+ });
30
+ }
31
+
32
+ /**
33
+ * 执行单个步骤
34
+ */
35
+ async function runStep(step: Step, project: ProjectConfig, sftp: SftpClient): Promise<void> {
36
+ switch (step.type) {
37
+ case 'log': {
38
+ const msg = resolveVariables(step.message, project);
39
+ console.log(msg);
40
+ break;
41
+ }
42
+ case 'local': {
43
+ const cmd = resolveVariables(step.cmd, project);
44
+ const cwd = step.cwd ? resolveVariables(step.cwd, project) : undefined;
45
+ await runLocal(cmd, cwd);
46
+ break;
47
+ }
48
+ case 'remote': {
49
+ const cmd = resolveVariables(step.cmd, project);
50
+ await execRemote(sftp, cmd);
51
+ break;
52
+ }
53
+ case 'uploadDir': {
54
+ const local = resolveVariables(step.local, project);
55
+ const remote = resolveVariables(step.remote, project);
56
+ await sftp.mkdir(remote, true);
57
+ await sftp.uploadDir(local, remote, { useFastput: true });
58
+ break;
59
+ }
60
+ case 'uploadFile': {
61
+ const local = resolveVariables(step.local, project);
62
+ const remote = resolveVariables(step.remote, project);
63
+ await sftp.fastPut(local, remote);
64
+ break;
65
+ }
66
+ case 'downloadDir': {
67
+ const remote = resolveVariables(step.remote, project);
68
+ const local = resolveVariables(step.local, project);
69
+ await sftp.downloadDir(remote, local);
70
+ break;
71
+ }
72
+ case 'downloadFile': {
73
+ const remote = resolveVariables(step.remote, project);
74
+ const local = resolveVariables(step.local, project);
75
+ await sftp.fastGet(remote, local);
76
+ break;
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 执行项目的所有步骤
83
+ */
84
+ export async function runSteps(steps: Step[], project: ProjectConfig, sftp: SftpClient): Promise<void> {
85
+ for (const step of steps) {
86
+ await runStep(step, project, sftp);
87
+ }
88
+ }