@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.
package/bin/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env tsx
2
+ import { createDeployProgram } from './shared.js';
3
+ createDeployProgram().parse();
package/bin/shared.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { Command } from 'commander';
2
+ import * as clack from '@clack/prompts';
3
+ import { runApp, runProject, showConfig } from '../src/app.js';
4
+ import { runInit } from '../src/init/app.js';
5
+ import { createServerCommand } from '../src/server/app.js';
6
+
7
+ /**
8
+ * 创建 deploy 命令程序
9
+ */
10
+ export function createDeployProgram(): Command {
11
+ const program = new Command();
12
+ program
13
+ .name('deploy')
14
+ .description('部署工具')
15
+ .version('1.0.0')
16
+ .action(async () => {
17
+ clack.intro('deploy');
18
+ try {
19
+ await runApp();
20
+ } catch (err) {
21
+ clack.log.error(`执行失败: ${err}`);
22
+ process.exit(1);
23
+ }
24
+ clack.outro('再见');
25
+ });
26
+
27
+ program
28
+ .command('init [path]')
29
+ .description('在 cwd(或指定目录)生成本地 deploy.config.json')
30
+ .action(async (path?: string) => {
31
+ clack.intro('deploy init');
32
+ try {
33
+ await runInit(path);
34
+ } catch (err) {
35
+ clack.log.error(`执行失败: ${err}`);
36
+ process.exit(1);
37
+ }
38
+ });
39
+
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
+ // 服务器管理子命令
62
+ program.addCommand(createServerCommand());
63
+
64
+ return program;
65
+ }
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@mindbase/deploy",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MindBase 部署工具",
5
5
  "type": "module",
6
6
  "bin": {
7
- "deploy": "./dist/bin/index.js"
7
+ "deploy": "./bin/index.ts"
8
8
  },
9
9
  "files": [
10
- "dist"
10
+ "bin",
11
+ "src",
12
+ "scripts"
11
13
  ],
12
14
  "engines": {
13
15
  "node": ">=20.0.0"
@@ -16,11 +18,9 @@
16
18
  "access": "public"
17
19
  },
18
20
  "scripts": {
19
- "build": "tsup",
20
- "dev": "tsup --watch",
21
- "clean": "rm -rf dist",
22
- "start": "node dist/bin/index.js",
23
- "prepublishOnly": "npm run build"
21
+ "dev": "tsx bin/index.ts",
22
+ "start": "tsx bin/index.ts",
23
+ "postinstall": "node scripts/ensure-tsx.cjs"
24
24
  },
25
25
  "dependencies": {
26
26
  "@clack/prompts": "^0.9.1",
@@ -28,13 +28,13 @@
28
28
  "commander": "^12.0.0",
29
29
  "cross-spawn": "^7.0.6",
30
30
  "picocolors": "^1.1.1",
31
- "ssh2-sftp-client": "^9.1.0"
31
+ "ssh2-sftp-client": "^9.1.0",
32
+ "tsx": "^4.19.0"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@types/cross-spawn": "^6.0.6",
35
36
  "@types/node": "^20.0.0",
36
37
  "@types/ssh2-sftp-client": "^9.0.4",
37
- "tsup": "^8.0.0",
38
38
  "typescript": "^5.1.3"
39
39
  }
40
- }
40
+ }
@@ -0,0 +1,18 @@
1
+ const { execSync } = require('child_process');
2
+
3
+ // 跳过检查(用于 CI/CD)
4
+ if (process.env.MINDBASE_SKIP_TSX_CHECK) {
5
+ process.exit(0);
6
+ }
7
+
8
+ try {
9
+ execSync('tsx --version', { stdio: 'pipe' });
10
+ } catch (err) {
11
+ console.log('\n📦 未检测到 tsx,正在全局安装...\n');
12
+ try {
13
+ execSync('npm install -g tsx', { stdio: 'inherit' });
14
+ } catch (installErr) {
15
+ console.error('\n❌ 安装失败,请手动执行:npm install -g tsx\n');
16
+ process.exit(1);
17
+ }
18
+ }
package/src/app.ts ADDED
@@ -0,0 +1,183 @@
1
+ import * as clack from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import { relative } from 'path';
4
+ import { noteBox } from '@mindbase/cli-ui';
5
+ import { getConfig, getConfigPath, discoverProjectConfigs, loadProjectConfig } from './config/manager.js';
6
+ import type { ProjectConfig } from './config/schema.js';
7
+ import { connectSftp } from './ssh/client.js';
8
+ import { runSteps } from './script/runner.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
+ * 查看配置
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 分组部署
88
+ */
89
+ export async function runApp(): Promise<void> {
90
+ const cwd = process.cwd();
91
+ const discovered = discoverProjectConfigs(cwd);
92
+ const globalConfig = getConfig();
93
+
94
+ if (discovered.length === 0) {
95
+ clack.log.error(`未在 ${cwd} 下找到任何 deploy.config.json,请先运行 deploy init`);
96
+ return;
97
+ }
98
+
99
+ // 0 个服务器直接报错
100
+ if (Object.keys(globalConfig.servers).length === 0) {
101
+ clack.log.error('没有已配置的服务器,请先运行 deploy server add');
102
+ return;
103
+ }
104
+
105
+ clack.intro('deploy');
106
+
107
+ // 选择项目
108
+ let selected: { path: string; config: ProjectConfig }[];
109
+
110
+ if (discovered.length === 1) {
111
+ const only = discovered[0];
112
+ clack.log.info(`仅发现一个项目: ${only.config.name}`);
113
+ const go = handleCancel<boolean>(
114
+ await clack.confirm({ message: `是否部署 ${only.config.name}?`, initialValue: true })
115
+ );
116
+ if (!go) {
117
+ clack.outro('已取消');
118
+ return;
119
+ }
120
+ selected = [only];
121
+ } else {
122
+ selected = handleCancel<typeof discovered>(
123
+ await clack.multiselect({
124
+ message: '选择要部署的项目(空格切换,a 全选)',
125
+ options: discovered.map((item) => ({
126
+ value: item,
127
+ label: `${item.config.name} (${relative(cwd, item.path) || item.path}) → ${pc.dim(item.config.server)}`,
128
+ })),
129
+ required: true,
130
+ })
131
+ );
132
+ }
133
+
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
+ }
146
+
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);
153
+ }
154
+
155
+ // 依次连接每个 server,组内项目依次部署
156
+ for (const [serverId, items] of groups) {
157
+ const serverConfig = globalConfig.servers[serverId];
158
+ const spinner = clack.spinner();
159
+ spinner.start(`连接 ${serverId}...`);
160
+ const sftp = await connectSftp('deploy', serverConfig);
161
+ spinner.stop('已连接');
162
+
163
+ try {
164
+ for (const { config } of items) {
165
+ clack.log.step(`开始部署 ${pc.cyan(config.name)} → ${pc.cyan(serverId)}`);
166
+ try {
167
+ await runSteps(config.steps, config, sftp);
168
+ clack.log.success(`${config.name} 部署完成`);
169
+ } catch (err) {
170
+ clack.log.error(`${config.name} 部署失败: ${err}`);
171
+ const cont = handleCancel<boolean>(
172
+ await clack.confirm({ message: '是否继续部署下一个项目?' })
173
+ );
174
+ if (!cont) break;
175
+ }
176
+ }
177
+ } finally {
178
+ await sftp.end();
179
+ }
180
+ }
181
+
182
+ clack.outro('全部完成');
183
+ }
@@ -0,0 +1,172 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
2
+ import { dirname, join, resolve, isAbsolute } from 'path';
3
+ import { homedir } from 'os';
4
+ import type { GlobalConfig, ProjectConfig } from './schema.js';
5
+
6
+ /** 项目级配置文件名 */
7
+ export const PROJECT_CONFIG_FILE = 'deploy.config.json';
8
+
9
+ /** 扫描时忽略的目录名 */
10
+ const SCAN_IGNORE_DIRS = new Set([
11
+ 'node_modules',
12
+ '.git',
13
+ 'dist',
14
+ 'build',
15
+ 'target',
16
+ '.next',
17
+ 'coverage',
18
+ '.cache',
19
+ ]);
20
+
21
+ /** 扫描深度上限 */
22
+ const SCAN_MAX_DEPTH = 5;
23
+
24
+ /** 默认全局配置 */
25
+ const defaultGlobalConfig: GlobalConfig = {
26
+ servers: {},
27
+ };
28
+
29
+ /** 全局配置目录与文件路径 */
30
+ const CONFIG_DIR = join(homedir(), '.mindbase');
31
+ const CONFIG_PATH = join(CONFIG_DIR, 'deploy.json');
32
+
33
+ /**
34
+ * 确保配置目录存在
35
+ */
36
+ function ensureConfigDir(): void {
37
+ if (!existsSync(CONFIG_DIR)) {
38
+ mkdirSync(CONFIG_DIR, { recursive: true });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * 读取全局配置(servers)
44
+ */
45
+ export function getConfig(): GlobalConfig {
46
+ if (!existsSync(CONFIG_PATH)) {
47
+ ensureConfigDir();
48
+ writeFileSync(CONFIG_PATH, JSON.stringify(defaultGlobalConfig, null, 2), 'utf-8');
49
+ return structuredClone(defaultGlobalConfig);
50
+ }
51
+
52
+ try {
53
+ const content = readFileSync(CONFIG_PATH, 'utf-8');
54
+ const parsed = JSON.parse(content);
55
+ return { servers: parsed.servers ?? {} };
56
+ } catch {
57
+ return structuredClone(defaultGlobalConfig);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 写入全局配置
63
+ */
64
+ export function setConfig(config: GlobalConfig): void {
65
+ ensureConfigDir();
66
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
67
+ }
68
+
69
+ /**
70
+ * 获取全局配置文件路径
71
+ */
72
+ export function getConfigPath(): string {
73
+ return CONFIG_PATH;
74
+ }
75
+
76
+ /**
77
+ * 解析配置文件实际路径:目录 → 拼 deploy.config.json;文件 → 直接用
78
+ */
79
+ function resolveConfigFilePath(targetPath?: string): string {
80
+ const cwd = targetPath ?? process.cwd();
81
+ const abs = isAbsolute(cwd) ? cwd : resolve(process.cwd(), cwd);
82
+ const stat = existsSync(abs) ? statSync(abs) : null;
83
+ if (stat && stat.isFile()) return abs;
84
+ return join(abs, PROJECT_CONFIG_FILE);
85
+ }
86
+
87
+ /**
88
+ * 解析 localDir 为绝对路径(相对配置文件所在目录)
89
+ */
90
+ function resolveLocalDir(localDir: string, configFilePath: string): string {
91
+ if (isAbsolute(localDir)) return localDir;
92
+ return resolve(dirname(configFilePath), localDir);
93
+ }
94
+
95
+ /**
96
+ * 加载项目级配置
97
+ * @param targetPath 目录或文件路径,缺省为 cwd
98
+ */
99
+ export function loadProjectConfig(targetPath?: string): ProjectConfig {
100
+ const configFilePath = resolveConfigFilePath(targetPath);
101
+ if (!existsSync(configFilePath)) {
102
+ throw new Error(`项目配置文件不存在: ${configFilePath}`);
103
+ }
104
+
105
+ const raw = readFileSync(configFilePath, 'utf-8');
106
+ const parsed = JSON.parse(raw) as ProjectConfig;
107
+ parsed.localDir = resolveLocalDir(parsed.localDir ?? '.', configFilePath);
108
+ return parsed;
109
+ }
110
+
111
+ /**
112
+ * 写入项目级配置
113
+ * @param config 项目配置
114
+ * @param targetPath 目录或文件路径,缺省为 cwd
115
+ */
116
+ export function saveProjectConfig(config: ProjectConfig, targetPath?: string): void {
117
+ const configFilePath = resolveConfigFilePath(targetPath);
118
+ mkdirSync(dirname(configFilePath), { recursive: true });
119
+ writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf-8');
120
+ }
121
+
122
+ /** 发现结果 */
123
+ export interface DiscoveredProject {
124
+ /** 配置文件绝对路径 */
125
+ path: string;
126
+ /** 已解析的 ProjectConfig */
127
+ config: ProjectConfig;
128
+ }
129
+
130
+ /**
131
+ * 扫描 rootPath 下所有 deploy.config.json
132
+ */
133
+ export function discoverProjectConfigs(rootPath?: string): DiscoveredProject[] {
134
+ const root = rootPath ?? process.cwd();
135
+ const results: DiscoveredProject[] = [];
136
+
137
+ function walk(dir: string, depth: number): void {
138
+ if (depth > SCAN_MAX_DEPTH) return;
139
+
140
+ let entries;
141
+ try {
142
+ entries = readdirSync(dir);
143
+ } catch {
144
+ return;
145
+ }
146
+
147
+ for (const entry of entries) {
148
+ if (SCAN_IGNORE_DIRS.has(entry)) continue;
149
+ const full = join(dir, entry);
150
+ let stat;
151
+ try {
152
+ stat = statSync(full);
153
+ } catch {
154
+ continue;
155
+ }
156
+
157
+ if (stat.isDirectory()) {
158
+ walk(full, depth + 1);
159
+ } else if (entry === PROJECT_CONFIG_FILE) {
160
+ try {
161
+ const config = loadProjectConfig(full);
162
+ results.push({ path: full, config });
163
+ } catch {
164
+ /* 跳过损坏的配置文件 */
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ walk(root, 0);
171
+ return results;
172
+ }
@@ -0,0 +1,100 @@
1
+ /** 步骤类型 */
2
+ export type StepType = 'log' | 'local' | 'remote' | 'uploadDir' | 'uploadFile' | 'downloadDir' | 'downloadFile';
3
+
4
+ /** 基础步骤 */
5
+ interface StepBase {
6
+ type: StepType;
7
+ }
8
+
9
+ /** 日志步骤 */
10
+ export interface LogStep extends StepBase {
11
+ type: 'log';
12
+ message: string;
13
+ }
14
+
15
+ /** 本地命令步骤 */
16
+ export interface LocalStep extends StepBase {
17
+ type: 'local';
18
+ cmd: string;
19
+ cwd?: string;
20
+ }
21
+
22
+ /** 远程命令步骤 */
23
+ export interface RemoteStep extends StepBase {
24
+ type: 'remote';
25
+ cmd: string;
26
+ }
27
+
28
+ /** 上传目录步骤 */
29
+ export interface UploadDirStep extends StepBase {
30
+ type: 'uploadDir';
31
+ local: string;
32
+ remote: string;
33
+ }
34
+
35
+ /** 上传文件步骤 */
36
+ export interface UploadFileStep extends StepBase {
37
+ type: 'uploadFile';
38
+ local: string;
39
+ remote: string;
40
+ }
41
+
42
+ /** 下载目录步骤(备份) */
43
+ export interface DownloadDirStep extends StepBase {
44
+ type: 'downloadDir';
45
+ remote: string;
46
+ local: string;
47
+ }
48
+
49
+ /** 下载文件步骤(备份) */
50
+ export interface DownloadFileStep extends StepBase {
51
+ type: 'downloadFile';
52
+ remote: string;
53
+ local: string;
54
+ }
55
+
56
+ /** 所有步骤类型联合 */
57
+ export type Step = LogStep | LocalStep | RemoteStep | UploadDirStep | UploadFileStep | DownloadDirStep | DownloadFileStep;
58
+
59
+ /** 服务器配置 - 密码认证 */
60
+ export interface PasswordServerConfig {
61
+ host: string;
62
+ port: number;
63
+ username: string;
64
+ password: string;
65
+ privateKeyPath?: never;
66
+ passphrase?: never;
67
+ }
68
+
69
+ /** 服务器配置 - 私钥认证 */
70
+ export interface PrivateKeyServerConfig {
71
+ host: string;
72
+ port: number;
73
+ username: string;
74
+ password?: never;
75
+ privateKeyPath: string;
76
+ passphrase?: string;
77
+ }
78
+
79
+ /** 服务器配置 */
80
+ export type ServerConfig = PasswordServerConfig | PrivateKeyServerConfig;
81
+
82
+ /** 项目配置(项目级 deploy.config.json) */
83
+ export interface ProjectConfig {
84
+ /** 项目显示名称 */
85
+ name: string;
86
+ /** 本地目录(绝对路径或相对配置文件的路径,加载时解析为绝对路径) */
87
+ localDir: string;
88
+ /** 目标服务器 ID(引用全局 servers) */
89
+ server: string;
90
+ /** 远端目录 */
91
+ remoteDir: string;
92
+ /** 部署步骤 */
93
+ steps: Step[];
94
+ }
95
+
96
+ /** 全局配置结构(~/.mindbase/deploy.json) */
97
+ export interface GlobalConfig {
98
+ /** 服务器列表 */
99
+ servers: Record<string, ServerConfig>;
100
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { runApp, runProject, showConfig } from './app.js';
2
+ export { runInit } from './init/app.js';