@mindbase/deploy 1.1.5 → 1.2.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.
package/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # deploy CLI
2
+
3
+ 多项目部署工具,支持批量部署到多台服务器。
4
+
5
+ ## 功能特点
6
+
7
+ - **交互式部署**:通过友好的命令行界面选择目标服务器和项目
8
+ - **笛卡尔组合部署**:选中的每个服务器都会部署全部选中的项目(N 个服务器 × M 个项目 = N×M 次部署)
9
+ - **灵活配置**:项目部署步骤完全自定义,支持本地命令、远程命令、文件上传/下载等
10
+ - **多环境支持**:部署时现场选择目标服务器,不再写死在配置中
11
+
12
+ ## 命令结构
13
+
14
+ ```bash
15
+ deploy # 部署:选择服务器 → 选择项目 → 开始部署
16
+ deploy init [path] # 初始化项目配置(path 缺省为当前目录)
17
+ deploy server ... # 服务器管理
18
+ ```
19
+
20
+ ## 快速开始
21
+
22
+ ### 1. 配置服务器
23
+
24
+ 首次使用需要先添加目标服务器:
25
+
26
+ ```bash
27
+ # 添加服务器
28
+ deploy server add
29
+
30
+ # 列出所有服务器
31
+ deploy server list
32
+
33
+ # 测试连接
34
+ deploy server test
35
+ ```
36
+
37
+ 服务器配置保存在 `~/.mindbase/deploy.json`,支持密码和私钥两种认证方式。
38
+
39
+ ### 2. 初始化项目
40
+
41
+ 在项目目录下运行:
42
+
43
+ ```bash
44
+ cd /path/to/your/project
45
+ deploy init
46
+ ```
47
+
48
+ 交互式引导会询问:
49
+ - 项目名称
50
+ - 目标服务器(选择已配置的服务器,用于 SFTP 连接浏览远端目录)
51
+ - 本地目录(默认 `./`)
52
+ - 远端目录(可浏览服务器目录或手动输入)
53
+ - 部署步骤(添加构建、上传、重启等步骤)
54
+
55
+ 完成后会在当前目录生成 `deploy.config.json` 配置文件。
56
+
57
+ ### 3. 部署
58
+
59
+ 在包含多个 `deploy.config.json` 的目录(如 monorepo 根目录)运行:
60
+
61
+ ```bash
62
+ deploy
63
+ ```
64
+
65
+ 交互流程:
66
+ 1. **选择服务器**(多选,空格切换,a 全选)
67
+ 2. **选择项目**(多选)
68
+ 3. **确认摘要**(显示 N×M=K 次部署)
69
+ 4. **开始部署**(依次连接每个服务器,部署所有选中项目)
70
+
71
+ ## 配置文件结构
72
+
73
+ `deploy.config.json` 示例:
74
+
75
+ ```json
76
+ {
77
+ "name": "photo",
78
+ "localDir": "./",
79
+ "remoteDir": "/application/lightUp-web/photo/",
80
+ "steps": [
81
+ { "type": "log", "message": "构建相册" },
82
+ { "type": "local", "cmd": "pnpm run build", "cwd": "${localDir}" },
83
+ { "type": "remote", "cmd": "mkdir -p ${remoteDir}" },
84
+ { "type": "uploadDir", "local": "${localDir}/dist", "remote": "${remoteDir}" }
85
+ ]
86
+ }
87
+ ```
88
+
89
+ ### 配置字段说明
90
+
91
+ | 字段 | 类型 | 说明 |
92
+ |---|---|---|
93
+ | `name` | string | 项目显示名称 |
94
+ | `localDir` | string | 本地目录(相对或绝对路径) |
95
+ | `remoteDir` | string | 远端部署目录 |
96
+ | `steps` | Step[] | 部署步骤数组 |
97
+
98
+ ### 步骤类型(Step)
99
+
100
+ | 类型 | 说明 | 示例 |
101
+ |---|---|---|
102
+ | `log` | 输出日志信息 | `{ "type": "log", "message": "开始构建" }` |
103
+ | `local` | 本地执行命令 | `{ "type": "local", "cmd": "npm run build", "cwd": "${localDir}" }` |
104
+ | `remote` | 远程执行命令 | `{ "type": "remote", "cmd": "pm2 restart myapp" }` |
105
+ | `uploadDir` | 上传整个目录 | `{ "type": "uploadDir", "local": "./dist", "remote": "/app/dist" }` |
106
+ | `uploadFile` | 上传单个文件 | `{ "type": "uploadFile", "local": "./package.json", "remote": "/app/package.json" }` |
107
+ | `downloadDir` | 下载目录(备份) | `{ "type": "downloadDir", "remote": "/app/data", "local": "./backup" }` |
108
+ | `downloadFile` | 下载文件(备份) | `{ "type": "downloadFile", "remote": "/app/config.json", "local": "./backup.json" }` |
109
+
110
+ ### 变量替换
111
+
112
+ 步骤中支持以下变量,运行时自动替换:
113
+
114
+ | 变量 | 说明 | 示例值 |
115
+ |---|---|---|
116
+ | `${localDir}` | 项目本地目录 | `/path/to/project` |
117
+ | `${remoteDir}` | 项目远端目录 | `/app/photo/` |
118
+ | `${projectName}` | 项目名称 | `photo` |
119
+ | `${date}` | 当前日期 | `2026-06-17` |
120
+ | `${datetime}` | 当前日期时间 | `2026-06-17_14-30-52` |
121
+ | `${timestamp}` | Unix 毫秒时间戳 | `1718621452000` |
122
+
123
+ 日期变量可用 `${name:格式}` 自定义格式,如 `${datetime:YYYYMMDD-HHmmss}` → `20260617-143052`。
124
+
125
+ 备份示例:
126
+
127
+ ```json
128
+ {
129
+ "type": "downloadDir",
130
+ "remote": "${remoteDir}",
131
+ "local": "${localDir}/backup_${datetime}"
132
+ }
133
+ ```
134
+
135
+ ## 服务器管理
136
+
137
+ ```bash
138
+ # 添加服务器
139
+ deploy server add
140
+
141
+ # 编辑服务器
142
+ deploy server edit
143
+
144
+ # 删除服务器
145
+ deploy server delete
146
+
147
+ # 测试连接
148
+ deploy server test
149
+
150
+ # 列出所有服务器
151
+ deploy server list
152
+ ```
153
+
154
+ ### 全局配置文件
155
+
156
+ 服务器配置保存在 `~/.mindbase/deploy.json`:
157
+
158
+ ```json
159
+ {
160
+ "servers": {
161
+ "own": {
162
+ "host": "192.168.1.100",
163
+ "port": 22,
164
+ "username": "root",
165
+ "password": "***"
166
+ },
167
+ "backup": {
168
+ "host": "192.168.1.101",
169
+ "port": 22,
170
+ "username": "root",
171
+ "privateKeyPath": "~/.ssh/id_rsa",
172
+ "passphrase": "***"
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
178
+ ## 使用场景
179
+
180
+ ### Monorepo 批量部署
181
+
182
+ ```bash
183
+ cd /path/to/monorepo # 包含多个子项目,每个都有 deploy.config.json
184
+ deploy # 选择服务器和项目,一键部署所有
185
+ ```
186
+
187
+ ### 单项目部署
188
+
189
+ ```bash
190
+ cd /path/to/project
191
+ deploy # 单选本项目,部署到选中服务器
192
+ ```
193
+
194
+ ### 多环境部署
195
+
196
+ ```bash
197
+ deploy # 多选 staging、production 两台服务器
198
+ # 选择所有项目,一次性部署到所有环境
199
+ ```
200
+
201
+ ## 边界情况处理
202
+
203
+ - **服务器连接失败**:询问是否跳过并继续下一个服务器
204
+ - **项目部署失败**:询问是否继续部署下一个项目(当前服务器连接保持)
205
+ - **无可用服务器**:提示先运行 `deploy server add`
206
+ - **无可用项目**:提示先运行 `deploy init`
207
+ - **用户取消**:任何交互步骤按 Ctrl+C 均可安全退出
208
+
209
+ ## 技术实现
210
+
211
+ - **依赖**:`@clack/prompts`(交互)、`ssh2-sftp-client`(SFTP)、`commander.js`(命令框架)
212
+ - **变量解析**:步骤中的变量在运行时替换
213
+ - **错误处理**:失败时询问用户决策,支持跳过/中止
214
+ - **日志输出**:统一使用 `@clack/prompts` 格式,逐行显示命令输出
215
+
216
+ ## 开发
217
+
218
+ ```bash
219
+ # 直接运行(无需构建)
220
+ cd cli/apps/deploy
221
+ tsx bin/index.ts
222
+
223
+ # 或在项目根目录
224
+ tsx cli/apps/deploy/bin/index.ts
225
+ ```
226
+
227
+ ## License
228
+
229
+ MIT
package/bin/shared.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import * as clack from '@clack/prompts';
3
- import { runApp, runProject, showConfig } from '../src/app.js';
3
+ import { runApp } from '../src/app.js';
4
4
  import { runInit } from '../src/init/app.js';
5
5
  import { createServerCommand } from '../src/server/app.js';
6
6
 
@@ -14,14 +14,12 @@ export function createDeployProgram(): Command {
14
14
  .description('部署工具')
15
15
  .version('1.0.0')
16
16
  .action(async () => {
17
- clack.intro('deploy');
18
17
  try {
19
18
  await runApp();
20
19
  } catch (err) {
21
20
  clack.log.error(`执行失败: ${err}`);
22
21
  process.exit(1);
23
22
  }
24
- clack.outro('再见');
25
23
  });
26
24
 
27
25
  program
@@ -37,27 +35,6 @@ export function createDeployProgram(): Command {
37
35
  }
38
36
  });
39
37
 
40
- program
41
- .command('run [path]')
42
- .description('部署单个项目(path 缺省为 cwd,可传目录或 deploy.config.json)')
43
- .option('-s, --server <server>', '覆盖项目配置中的 server')
44
- .action(async (path: string | undefined, options: { server?: string }) => {
45
- clack.intro('deploy run');
46
- try {
47
- await runProject(path, options.server);
48
- } catch (err) {
49
- clack.log.error(`执行失败: ${err}`);
50
- process.exit(1);
51
- }
52
- });
53
-
54
- program
55
- .command('config')
56
- .description('查看全局 servers 配置')
57
- .action(() => {
58
- showConfig();
59
- });
60
-
61
38
  // 服务器管理子命令
62
39
  program.addCommand(createServerCommand());
63
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindbase/deploy",
3
- "version": "1.1.5",
3
+ "version": "1.2.1",
4
4
  "description": "MindBase 部署工具",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "@clack/prompts": "^0.9.1",
22
22
  "commander": "^12.0.0",
23
23
  "cross-spawn": "^7.0.6",
24
+ "dayjs": "^1.11.13",
24
25
  "picocolors": "^1.1.1",
25
26
  "ssh2-sftp-client": "^9.1.0",
26
27
  "tsx": "^4.19.0",
package/src/app.ts CHANGED
@@ -2,7 +2,7 @@ import * as clack from '@clack/prompts';
2
2
  import pc from 'picocolors';
3
3
  import { relative } from 'path';
4
4
  import { noteBox } from '@mindbase/cli-ui';
5
- import { getConfig, getConfigPath, discoverProjectConfigs, loadProjectConfig } from './config/manager.js';
5
+ import { getConfig, discoverProjectConfigs } from './config/manager.js';
6
6
  import type { ProjectConfig } from './config/schema.js';
7
7
  import { connectSftp } from './ssh/client.js';
8
8
  import { runSteps } from './script/runner.js';
@@ -17,86 +17,18 @@ function handleCancel<T>(result: T | symbol): T {
17
17
  }
18
18
 
19
19
  /**
20
- * 查看配置
21
- */
22
- export function showConfig(): void {
23
- clack.intro('配置信息');
24
- const config = getConfig();
25
- const path = getConfigPath();
26
- noteBox(`配置文件: ${path}\n\n${JSON.stringify(config, null, 2)}`, '当前配置');
27
- clack.outro('完成');
28
- }
29
-
30
- /**
31
- * 显示项目配置摘要并让用户确认部署
32
- */
33
- async function confirmProjectDeploy(project: ProjectConfig, serverId: string): Promise<boolean> {
34
- const summary = [
35
- `名称: ${project.name}`,
36
- `服务器: ${serverId}`,
37
- `本地目录: ${project.localDir}`,
38
- `远端目录: ${project.remoteDir}`,
39
- `步骤数: ${project.steps.length}`,
40
- ].join('\n');
41
- noteBox(summary, '即将部署');
42
- return handleCancel<boolean>(
43
- await clack.confirm({ message: '确认部署?', initialValue: true })
44
- );
45
- }
46
-
47
- /**
48
- * 直接运行指定项目(单项目模式)
49
- */
50
- export async function runProject(targetPath?: string, serverOverride?: string): Promise<void> {
51
- const project = loadProjectConfig(targetPath);
52
- const globalConfig = getConfig();
53
-
54
- const serverId = serverOverride || project.server;
55
- const serverConfig = globalConfig.servers[serverId];
56
- if (!serverConfig) {
57
- clack.log.error(`服务器 "${serverId}" 不存在,请先运行 deploy server add`);
58
- process.exit(1);
59
- }
60
-
61
- clack.intro(`部署 ${pc.cyan(project.name)} → ${pc.cyan(serverId)}`);
62
-
63
- if (!(await confirmProjectDeploy(project, serverId))) {
64
- clack.log.info('已取消');
65
- clack.outro('完成');
66
- return;
67
- }
68
-
69
- const spinner = clack.spinner();
70
- spinner.start('连接服务器...');
71
- const sftp = await connectSftp(project.name, serverConfig);
72
- spinner.stop('已连接');
73
-
74
- try {
75
- await runSteps(project.steps, project, sftp);
76
- clack.log.success(`${project.name} 部署完成`);
77
- } catch (err) {
78
- clack.log.error(`部署失败: ${err}`);
79
- } finally {
80
- await sftp.end();
81
- }
82
-
83
- clack.outro('完成');
84
- }
85
-
86
- /**
87
- * 交互式主流程:扫描 cwd 下所有 deploy.config.json → 多选 → 按 server 分组部署
20
+ * 交互式主流程:选 server → 选项目 → 按笛卡尔分组部署
88
21
  */
89
22
  export async function runApp(): Promise<void> {
90
23
  const cwd = process.cwd();
91
24
  const discovered = discoverProjectConfigs(cwd);
92
25
  const globalConfig = getConfig();
93
26
 
27
+ // 边界校验(intro 之前)
94
28
  if (discovered.length === 0) {
95
29
  clack.log.error(`未在 ${cwd} 下找到任何 deploy.config.json,请先运行 deploy init`);
96
30
  return;
97
31
  }
98
-
99
- // 0 个服务器直接报错
100
32
  if (Object.keys(globalConfig.servers).length === 0) {
101
33
  clack.log.error('没有已配置的服务器,请先运行 deploy server add');
102
34
  return;
@@ -104,8 +36,21 @@ export async function runApp(): Promise<void> {
104
36
 
105
37
  clack.intro('deploy');
106
38
 
107
- // 选择项目
108
- let selected: { path: string; config: ProjectConfig }[];
39
+ // 多选 server
40
+ const serverIds = Object.keys(globalConfig.servers);
41
+ const selectedServers = handleCancel<string[]>(
42
+ await clack.multiselect({
43
+ message: '选择要部署到的服务器(空格切换,a 全选)',
44
+ options: serverIds.map((id) => ({
45
+ value: id,
46
+ label: `${id} (${globalConfig.servers[id].host})`,
47
+ })),
48
+ required: true,
49
+ })
50
+ );
51
+
52
+ // 多选项目
53
+ let selectedProjects: typeof discovered;
109
54
 
110
55
  if (discovered.length === 1) {
111
56
  const only = discovered[0];
@@ -117,57 +62,68 @@ export async function runApp(): Promise<void> {
117
62
  clack.outro('已取消');
118
63
  return;
119
64
  }
120
- selected = [only];
65
+ selectedProjects = [only];
121
66
  } else {
122
- selected = handleCancel<typeof discovered>(
67
+ selectedProjects = handleCancel<typeof discovered>(
123
68
  await clack.multiselect({
124
69
  message: '选择要部署的项目(空格切换,a 全选)',
125
70
  options: discovered.map((item) => ({
126
71
  value: item,
127
- label: `${item.config.name} (${relative(cwd, item.path) || item.path}) → ${pc.dim(item.config.server)}`,
72
+ label: `${item.config.name} (${relative(cwd, item.path) || item.path})`,
128
73
  })),
129
74
  required: true,
130
75
  })
131
76
  );
132
77
  }
133
78
 
134
- // 校验所有选中项目的 server 都存在
135
- const missingServers = new Set<string>();
136
- for (const item of selected) {
137
- if (!globalConfig.servers[item.config.server]) {
138
- missingServers.add(item.config.server);
139
- }
140
- }
141
- if (missingServers.size > 0) {
142
- clack.log.error(`以下服务器未配置: ${[...missingServers].join(', ')}`);
143
- clack.outro('已中止');
144
- return;
145
- }
79
+ // 确认摘要
80
+ const summaryLines = [
81
+ `服务器: ${selectedServers.map((s) => `${s} (${globalConfig.servers[s].host})`).join(', ')}`,
82
+ `项目: ${selectedProjects.map((p) => p.config.name).join(', ')}`,
83
+ ].join('\n');
84
+ noteBox(summaryLines, '即将部署');
146
85
 
147
- // server 分组
148
- const groups = new Map<string, { path: string; config: ProjectConfig }[]>();
149
- for (const item of selected) {
150
- const arr = groups.get(item.config.server) ?? [];
151
- arr.push(item);
152
- groups.set(item.config.server, arr);
86
+ const confirmed = handleCancel<boolean>(
87
+ await clack.confirm({ message: '确认开始部署?', initialValue: true })
88
+ );
89
+ if (!confirmed) {
90
+ clack.outro('已取消');
91
+ return;
153
92
  }
154
93
 
155
- // 依次连接每个 server,组内项目依次部署
156
- for (const [serverId, items] of groups) {
94
+ // server 分组遍历(笛卡尔:每个 server 部署全部选中项目)
95
+ let anyFailure = false;
96
+ for (const serverId of selectedServers) {
157
97
  const serverConfig = globalConfig.servers[serverId];
158
98
  const spinner = clack.spinner();
159
- spinner.start(`连接 ${serverId}...`);
160
- const sftp = await connectSftp('deploy', serverConfig);
161
- spinner.stop('已连接');
99
+ spinner.start(`连接 ${serverId} (${serverConfig.host})...`);
100
+ let sftp;
101
+ try {
102
+ sftp = await connectSftp('deploy', serverConfig);
103
+ } catch (err) {
104
+ spinner.stop(`连接失败: ${serverId}`);
105
+ clack.log.error(`${serverId} 连接失败: ${err}`);
106
+ anyFailure = true;
107
+ const cont = handleCancel<boolean>(
108
+ await clack.confirm({ message: `${serverId} 不可用,是否跳过并继续下一个服务器?` })
109
+ );
110
+ if (!cont) {
111
+ clack.outro('已中止');
112
+ return;
113
+ }
114
+ continue;
115
+ }
116
+ spinner.stop(`已连接 ${serverId}`);
162
117
 
163
118
  try {
164
- for (const { config } of items) {
119
+ for (const { config } of selectedProjects) {
165
120
  clack.log.step(`开始部署 ${pc.cyan(config.name)} → ${pc.cyan(serverId)}`);
166
121
  try {
167
122
  await runSteps(config.steps, config, sftp);
168
- clack.log.success(`${config.name} 部署完成`);
123
+ clack.log.success(`${config.name} → ${serverId} 完成`);
169
124
  } catch (err) {
170
- clack.log.error(`${config.name} 部署失败: ${err}`);
125
+ anyFailure = true;
126
+ clack.log.error(`${config.name} → ${serverId} 失败: ${err}`);
171
127
  const cont = handleCancel<boolean>(
172
128
  await clack.confirm({ message: '是否继续部署下一个项目?' })
173
129
  );
@@ -179,5 +135,5 @@ export async function runApp(): Promise<void> {
179
135
  }
180
136
  }
181
137
 
182
- clack.outro('全部完成');
138
+ clack.outro(anyFailure ? '完成(存在失败项)' : '全部完成');
183
139
  }
@@ -85,8 +85,6 @@ export interface ProjectConfig {
85
85
  name: string;
86
86
  /** 本地目录(绝对路径或相对配置文件的路径,加载时解析为绝对路径) */
87
87
  localDir: string;
88
- /** 目标服务器 ID(引用全局 servers) */
89
- server: string;
90
88
  /** 远端目录 */
91
89
  remoteDir: string;
92
90
  /** 部署步骤 */
package/src/init/app.ts CHANGED
@@ -50,6 +50,11 @@ const VARS_REFERENCE = [
50
50
  ' ${localDir} 项目本地目录',
51
51
  ' ${remoteDir} 项目远程目录',
52
52
  ' ${projectName} 项目名称',
53
+ ' ${date} 日期 (YYYY-MM-DD)',
54
+ ' ${datetime} 日期时间 (YYYY-MM-DD_HH-mm-ss)',
55
+ ' ${timestamp} Unix 毫秒时间戳',
56
+ '',
57
+ ' 可用 ${name:格式} 自定义,如 ${datetime:YYYYMMDD-HHmmss}',
53
58
  ].join('\n');
54
59
 
55
60
  /** 步骤类型示例 */
@@ -94,15 +99,15 @@ const STEP_EXAMPLES: Record<StepType, string> = {
94
99
  VARS_REFERENCE,
95
100
  '',
96
101
  '示例:',
97
- ' 远端: ${remoteDir}backup/',
98
- ' 本地: ${localDir}/backup',
102
+ ' 远端: ${remoteDir}',
103
+ ' 本地: ${localDir}/backup_${datetime}',
99
104
  ].join('\n'),
100
105
  downloadFile: [
101
106
  VARS_REFERENCE,
102
107
  '',
103
108
  '示例:',
104
109
  ' 远端: ${remoteDir}config.json',
105
- ' 本地: ${localDir}/config.json.bak',
110
+ ' 本地: ${localDir}/config.json.bak_${date}',
106
111
  ].join('\n'),
107
112
  };
108
113
 
@@ -384,7 +389,6 @@ export async function runInit(targetPath?: string): Promise<void> {
384
389
  const projectConfig: ProjectConfig = {
385
390
  name,
386
391
  localDir,
387
- server: serverId,
388
392
  remoteDir,
389
393
  steps,
390
394
  };
@@ -1,22 +1,62 @@
1
+ import dayjs from 'dayjs';
1
2
  import type { ProjectConfig } from '../config/schema.js';
2
3
 
3
- /**
4
- * 内置变量名
5
- */
6
- const BUILT_IN_VARS = ['localDir', 'remoteDir', 'projectName'] as const;
4
+ /** 内置变量名(含日期时间变量) */
5
+ const BUILT_IN_VARS = ['localDir', 'remoteDir', 'projectName', 'date', 'datetime', 'timestamp'] as const;
6
+
7
+ /** 日期变量预设及其默认格式(timestamp 固定为毫秒,无 format) */
8
+ const DATE_VAR_DEFAULTS: Record<string, string | null> = {
9
+ date: 'YYYY-MM-DD',
10
+ datetime: 'YYYY-MM-DD_HH-mm-ss',
11
+ timestamp: null, // 特殊:输出毫秒
12
+ };
13
+
14
+ /** 变量解析上下文(运行时动态变量) */
15
+ export interface ResolveContext {
16
+ /** 时间锚点;缺省取当前时间。同一次部署应传同一个 Date 保证一致性 */
17
+ now?: Date;
18
+ }
7
19
 
8
20
  /**
9
- * 替换字符串中的 ${varName} 占位符
21
+ * 替换字符串中的 ${varName} 或 ${name:format} 占位符
22
+ * - 静态变量:${localDir}、${remoteDir}、${projectName}
23
+ * - 日期变量:${date}、${datetime}、${timestamp},可加 :format 自定义格式,如 ${datetime:YYYYMMDD-HHmmss}
24
+ * - 未知变量:原样保留(向后兼容)
10
25
  */
11
- export function resolveVariables(template: string, project: ProjectConfig): string {
12
- const vars: Record<string, string> = {
26
+ export function resolveVariables(
27
+ template: string,
28
+ project: ProjectConfig,
29
+ ctx: ResolveContext = {},
30
+ ): string {
31
+ const staticVars: Record<string, string> = {
13
32
  localDir: project.localDir,
14
33
  remoteDir: project.remoteDir,
15
34
  projectName: project.name,
16
35
  };
36
+ const now = ctx.now ?? new Date();
37
+
38
+ return template.replace(/\$\{([^}]+)\}/g, (match, expr: string) => {
39
+ // 按第一个冒号分割 name 和 format(format 内部可能含冒号,如 HH:mm:ss)
40
+ const colon = expr.indexOf(':');
41
+ const name = colon === -1 ? expr : expr.slice(0, colon);
42
+ const format = colon === -1 ? '' : expr.slice(colon + 1);
43
+
44
+ // 1. 静态变量优先
45
+ if (staticVars[name] !== undefined) {
46
+ return staticVars[name];
47
+ }
48
+
49
+ // 2. 日期变量
50
+ if (name in DATE_VAR_DEFAULTS) {
51
+ if (name === 'timestamp') {
52
+ return String(dayjs(now).valueOf());
53
+ }
54
+ // date/datetime 用默认格式或自定义格式
55
+ return dayjs(now).format(format || (DATE_VAR_DEFAULTS[name] as string));
56
+ }
17
57
 
18
- return template.replace(/\$\{(\w+)\}/g, (match, key: string) => {
19
- return vars[key] !== undefined ? vars[key] : match;
58
+ // 3. 未知变量原样保留(向后兼容)
59
+ return match;
20
60
  });
21
61
  }
22
62
 
@@ -38,27 +38,27 @@ function runLocal(cmd: string, cwd?: string): Promise<void> {
38
38
  /**
39
39
  * 执行单个步骤
40
40
  */
41
- async function runStep(step: Step, project: ProjectConfig, sftp: SftpClient): Promise<void> {
41
+ async function runStep(step: Step, project: ProjectConfig, sftp: SftpClient, now: Date): Promise<void> {
42
42
  switch (step.type) {
43
43
  case 'log': {
44
- const msg = resolveVariables(step.message, project);
44
+ const msg = resolveVariables(step.message, project, { now });
45
45
  clack.log.message(msg);
46
46
  break;
47
47
  }
48
48
  case 'local': {
49
- const cmd = resolveVariables(step.cmd, project);
50
- const cwd = step.cwd ? resolveVariables(step.cwd, project) : undefined;
49
+ const cmd = resolveVariables(step.cmd, project, { now });
50
+ const cwd = step.cwd ? resolveVariables(step.cwd, project, { now }) : undefined;
51
51
  await runLocal(cmd, cwd);
52
52
  break;
53
53
  }
54
54
  case 'remote': {
55
- const cmd = resolveVariables(step.cmd, project);
55
+ const cmd = resolveVariables(step.cmd, project, { now });
56
56
  await execRemote(sftp, cmd);
57
57
  break;
58
58
  }
59
59
  case 'uploadDir': {
60
- const local = resolveVariables(step.local, project);
61
- const remote = resolveVariables(step.remote, project);
60
+ const local = resolveVariables(step.local, project, { now });
61
+ const remote = resolveVariables(step.remote, project, { now });
62
62
  clack.log.step(`上传目录: ${local} → ${remote}`);
63
63
  await sftp.mkdir(remote, true);
64
64
  await sftp.uploadDir(local, remote, { useFastput: true });
@@ -66,24 +66,24 @@ async function runStep(step: Step, project: ProjectConfig, sftp: SftpClient): Pr
66
66
  break;
67
67
  }
68
68
  case 'uploadFile': {
69
- const local = resolveVariables(step.local, project);
70
- const remote = resolveVariables(step.remote, project);
69
+ const local = resolveVariables(step.local, project, { now });
70
+ const remote = resolveVariables(step.remote, project, { now });
71
71
  clack.log.step(`上传文件: ${local} → ${remote}`);
72
72
  await sftp.fastPut(local, remote);
73
73
  clack.log.success('上传文件完成');
74
74
  break;
75
75
  }
76
76
  case 'downloadDir': {
77
- const remote = resolveVariables(step.remote, project);
78
- const local = resolveVariables(step.local, project);
77
+ const remote = resolveVariables(step.remote, project, { now });
78
+ const local = resolveVariables(step.local, project, { now });
79
79
  clack.log.step(`下载目录: ${remote} → ${local}`);
80
80
  await sftp.downloadDir(remote, local);
81
81
  clack.log.success('下载目录完成');
82
82
  break;
83
83
  }
84
84
  case 'downloadFile': {
85
- const remote = resolveVariables(step.remote, project);
86
- const local = resolveVariables(step.local, project);
85
+ const remote = resolveVariables(step.remote, project, { now });
86
+ const local = resolveVariables(step.local, project, { now });
87
87
  clack.log.step(`下载文件: ${remote} → ${local}`);
88
88
  await sftp.fastGet(remote, local);
89
89
  clack.log.success('下载文件完成');
@@ -96,7 +96,8 @@ async function runStep(step: Step, project: ProjectConfig, sftp: SftpClient): Pr
96
96
  * 执行项目的所有步骤
97
97
  */
98
98
  export async function runSteps(steps: Step[], project: ProjectConfig, sftp: SftpClient): Promise<void> {
99
+ const now = new Date(); // 整个项目部署共用同一时间戳
99
100
  for (const step of steps) {
100
- await runStep(step, project, sftp);
101
+ await runStep(step, project, sftp, now);
101
102
  }
102
103
  }