nodebbs 0.3.3 → 0.4.2
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 +22 -17
- package/dist/commands/backup/all.d.ts +9 -0
- package/dist/commands/backup/all.js +71 -0
- package/dist/commands/{db/backup.d.ts → backup/db.d.ts} +1 -1
- package/dist/commands/{db/backup.js → backup/db.js} +8 -8
- package/dist/commands/backup/uploads.d.ts +13 -0
- package/dist/commands/backup/uploads.js +116 -0
- package/dist/commands/clean/index.d.ts +1 -1
- package/dist/commands/clean/index.js +12 -12
- package/dist/commands/db/reset.js +4 -4
- package/dist/commands/import/all.d.ts +9 -0
- package/dist/commands/import/all.js +123 -0
- package/dist/commands/{db/import.d.ts → import/db.d.ts} +1 -1
- package/dist/commands/{db/import.js → import/db.js} +16 -22
- package/dist/commands/import/uploads.d.ts +13 -0
- package/dist/commands/import/uploads.js +141 -0
- package/dist/commands/logs/all.js +2 -2
- package/dist/commands/logs/api.js +2 -2
- package/dist/commands/logs/db.js +1 -1
- package/dist/commands/logs/redis.js +1 -1
- package/dist/commands/logs/web.js +1 -1
- package/dist/commands/pack/index.js +30 -27
- package/dist/commands/restart/index.js +6 -8
- package/dist/commands/shell/api.js +1 -1
- package/dist/commands/shell/db.js +2 -2
- package/dist/commands/shell/redis.js +2 -2
- package/dist/commands/shell/web.js +1 -1
- package/dist/commands/start/index.d.ts +1 -1
- package/dist/commands/start/index.js +12 -22
- package/dist/commands/status/index.js +2 -2
- package/dist/commands/stop/index.d.ts +1 -1
- package/dist/commands/stop/index.js +7 -7
- package/dist/commands/upgrade/index.js +5 -5
- package/dist/interactive.js +28 -28
- package/dist/templates/config.yml +22 -0
- package/dist/templates/docker-compose.yml +3 -13
- package/dist/templates/env +1 -2
- package/dist/utils/docker.js +28 -20
- package/dist/utils/env.d.ts +1 -1
- package/dist/utils/env.js +40 -42
- package/dist/utils/logger.d.ts +2 -2
- package/dist/utils/logger.js +8 -8
- package/dist/utils/selection.d.ts +2 -2
- package/dist/utils/selection.js +7 -10
- package/oclif.manifest.json +213 -67
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -71,34 +71,39 @@ CLI 会自动根据当前目录内容判断部署模式:
|
|
|
71
71
|
```bash
|
|
72
72
|
$ nodebbs
|
|
73
73
|
? 选择命令:
|
|
74
|
-
❯ start
|
|
75
|
-
stop
|
|
76
|
-
restart
|
|
77
|
-
upgrade
|
|
78
|
-
status
|
|
79
|
-
logs
|
|
74
|
+
❯ start 启动
|
|
75
|
+
stop 停止
|
|
76
|
+
restart 重启
|
|
77
|
+
upgrade 升级
|
|
78
|
+
status 查看状态
|
|
79
|
+
logs 查看日志 [+]
|
|
80
|
+
db 数据库操作 (种子数据, 重置等) [+]
|
|
81
|
+
backup 备份数据 (数据库, 上传文件等) [+]
|
|
82
|
+
import 导入/恢复数据 [+]
|
|
83
|
+
clean 清理 Docker 缓存和残留资源
|
|
80
84
|
shell 进入容器终端 [+]
|
|
81
|
-
db 数据库操作 (备份, 迁移, 种子数据等) [+]
|
|
82
85
|
pack 生成离线部署包
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
──────────────
|
|
87
|
+
❯ 返回
|
|
88
|
+
退出
|
|
86
89
|
```
|
|
87
90
|
|
|
88
91
|
#### 命令说明
|
|
89
92
|
|
|
90
93
|
| 命令 | 说明 |
|
|
91
|
-
|------|---------|
|
|
92
|
-
| **start** |
|
|
94
|
+
|------|---------|
|
|
95
|
+
| **start** | 启动(根据当前配置环境)|
|
|
93
96
|
| **stop** | 停止所有服务 |
|
|
94
|
-
| **restart** |
|
|
95
|
-
| **upgrade** |
|
|
97
|
+
| **restart** | 重启(相当于 `docker compose up --force-recreate`)|
|
|
98
|
+
| **upgrade** | 升级(拉取最新 Docker 镜像或重新构建本地镜像)|
|
|
96
99
|
| **status** | 查看所有容器的运行状态和健康检查结果 |
|
|
97
|
-
| **logs** |
|
|
98
|
-
| **
|
|
99
|
-
| **
|
|
100
|
+
| **logs** | 查看日志(支持选择特定服务 API/Web/DB/Redis)|
|
|
101
|
+
| **db** | 数据库操作:种子数据 (seed)、重置 (reset) 等 |
|
|
102
|
+
| **backup** | 备份数据:<br>• 数据库 (db)<br>• 上传文件 (uploads)<br>• 一键全部备份 (all) |
|
|
103
|
+
| **import** | 恢复数据:<br>• 数据库 (db)<br>• 上传文件 (uploads)<br>• 一键全部恢复 (all) |
|
|
100
104
|
| **pack** | 生成离线部署包(**仅限源码模式**),方便在无网环境部署 |
|
|
101
105
|
| **clean** | 清理工具(删除未使用镜像、容器、卷,释放磁盘空间)|
|
|
106
|
+
| **shell** | 进入容器终端进行调试(支持选择特定服务)|
|
|
102
107
|
|
|
103
108
|
### 离线服务器部署
|
|
104
109
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class BackupAll extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static flags: {
|
|
5
|
+
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
6
|
+
output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
};
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { logger } from '../../utils/logger.js';
|
|
5
|
+
import BackupDb from './db.js';
|
|
6
|
+
import BackupUploads from './uploads.js';
|
|
7
|
+
export default class BackupAll extends Command {
|
|
8
|
+
static description = '一键备份全部数据';
|
|
9
|
+
static flags = {
|
|
10
|
+
env: Flags.string({
|
|
11
|
+
char: 'e',
|
|
12
|
+
description: '运行环境 (production, lowmem, default)',
|
|
13
|
+
options: ['production', 'lowmem', 'default'],
|
|
14
|
+
}),
|
|
15
|
+
output: Flags.string({
|
|
16
|
+
char: 'o',
|
|
17
|
+
description: '输出目录路径',
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
async run() {
|
|
21
|
+
const { flags } = await this.parse(BackupAll);
|
|
22
|
+
// 生成备份目录
|
|
23
|
+
const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-').slice(0, 19);
|
|
24
|
+
const backupDirName = flags.output || `backup_${timestamp}`;
|
|
25
|
+
const backupDir = path.resolve(process.cwd(), backupDirName);
|
|
26
|
+
// 创建备份目录
|
|
27
|
+
if (!fs.existsSync(backupDir)) {
|
|
28
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
logger.info(`开始完整备份,输出目录: ${backupDir}`);
|
|
31
|
+
logger.info('='.repeat(50));
|
|
32
|
+
const dbBackupFile = path.join(backupDir, 'database.sql');
|
|
33
|
+
const uploadsBackupFile = path.join(backupDir, 'uploads.tar.gz');
|
|
34
|
+
// 1. 备份数据库
|
|
35
|
+
logger.info('\n[1/2] 备份数据库...');
|
|
36
|
+
try {
|
|
37
|
+
const dbArgs = ['-o', dbBackupFile];
|
|
38
|
+
if (flags.env) {
|
|
39
|
+
dbArgs.push('-e', flags.env);
|
|
40
|
+
}
|
|
41
|
+
await BackupDb.run(dbArgs);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
logger.error('数据库备份失败,终止操作');
|
|
45
|
+
this.exit(1);
|
|
46
|
+
}
|
|
47
|
+
// 2. 备份上传文件
|
|
48
|
+
logger.info('\n[2/2] 备份上传文件...');
|
|
49
|
+
try {
|
|
50
|
+
await BackupUploads.run(['-o', uploadsBackupFile]);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
logger.warning('上传文件备份失败 (可能卷不存在)');
|
|
54
|
+
// 不终止,因为数据库已备份
|
|
55
|
+
}
|
|
56
|
+
// 生成备份清单
|
|
57
|
+
logger.info('\n' + '='.repeat(50));
|
|
58
|
+
logger.success('完整备份完成!');
|
|
59
|
+
logger.info(`\n备份目录: ${backupDir}`);
|
|
60
|
+
logger.info('包含文件:');
|
|
61
|
+
const files = fs.readdirSync(backupDir);
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const filePath = path.join(backupDir, file);
|
|
64
|
+
const stats = fs.statSync(filePath);
|
|
65
|
+
const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
|
|
66
|
+
logger.info(` - ${file} (${sizeMB} MB)`);
|
|
67
|
+
}
|
|
68
|
+
logger.info('\n使用以下命令恢复数据:');
|
|
69
|
+
logger.info(` nodebbs import all -d ${backupDir}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
|
-
export default class
|
|
2
|
+
export default class BackupDb extends Command {
|
|
3
3
|
static description: string;
|
|
4
4
|
static flags: {
|
|
5
5
|
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Command, Flags } from '@oclif/core';
|
|
2
|
-
import
|
|
3
|
-
import { logger } from '../../utils/logger.js';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
4
3
|
import { execa } from 'execa';
|
|
5
4
|
import fs from 'node:fs';
|
|
6
5
|
import path from 'node:path';
|
|
7
|
-
import
|
|
6
|
+
import { getComposeFiles } from '../../utils/docker.js';
|
|
7
|
+
import { logger } from '../../utils/logger.js';
|
|
8
8
|
import { EnvFlag, selectEnvironment } from '../../utils/selection.js';
|
|
9
|
-
export default class
|
|
10
|
-
static description = '备份数据库
|
|
9
|
+
export default class BackupDb extends Command {
|
|
10
|
+
static description = '备份数据库';
|
|
11
11
|
static flags = {
|
|
12
12
|
env: EnvFlag,
|
|
13
13
|
output: Flags.string({
|
|
@@ -16,14 +16,14 @@ export default class DbBackup extends Command {
|
|
|
16
16
|
}),
|
|
17
17
|
};
|
|
18
18
|
async run() {
|
|
19
|
-
const { flags } = await this.parse(
|
|
19
|
+
const { flags } = await this.parse(BackupDb);
|
|
20
20
|
// 1. 选择环境
|
|
21
21
|
const env = await selectEnvironment(flags.env);
|
|
22
22
|
// 2. 确定输出文件
|
|
23
23
|
let outputFile = flags.output;
|
|
24
24
|
if (!outputFile) {
|
|
25
|
-
const timestamp = new Date().toISOString().
|
|
26
|
-
outputFile = `
|
|
25
|
+
const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-').slice(0, 19);
|
|
26
|
+
outputFile = `backup_db_${timestamp}.sql`;
|
|
27
27
|
}
|
|
28
28
|
// 确保绝对路径
|
|
29
29
|
const outputPath = path.resolve(process.cwd(), outputFile);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class BackupUploads extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static flags: {
|
|
5
|
+
output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
6
|
+
};
|
|
7
|
+
run(): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* 获取上传文件数据卷名称
|
|
10
|
+
* 优先级: 1. .env 中的 COMPOSE_PROJECT_NAME 2. 当前目录名 3. 自动搜索
|
|
11
|
+
*/
|
|
12
|
+
private getUploadsVolumeName;
|
|
13
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
export default class BackupUploads extends Command {
|
|
8
|
+
static description = '备份上传文件';
|
|
9
|
+
static flags = {
|
|
10
|
+
output: Flags.string({
|
|
11
|
+
char: 'o',
|
|
12
|
+
description: '输出文件路径 (.tar.gz)',
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
15
|
+
async run() {
|
|
16
|
+
const { flags } = await this.parse(BackupUploads);
|
|
17
|
+
// 获取上传文件卷名
|
|
18
|
+
const volumeName = await this.getUploadsVolumeName();
|
|
19
|
+
if (!volumeName) {
|
|
20
|
+
logger.error('未找到上传文件数据卷。请确保服务已启动过至少一次。');
|
|
21
|
+
this.exit(1);
|
|
22
|
+
}
|
|
23
|
+
// 确定输出文件
|
|
24
|
+
let outputFile = flags.output;
|
|
25
|
+
if (!outputFile) {
|
|
26
|
+
const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-').slice(0, 19);
|
|
27
|
+
outputFile = `backup_uploads_${timestamp}.tar.gz`;
|
|
28
|
+
}
|
|
29
|
+
// 确保绝对路径
|
|
30
|
+
const outputPath = path.resolve(process.cwd(), outputFile);
|
|
31
|
+
const outputDir = path.dirname(outputPath);
|
|
32
|
+
const outputFileName = path.basename(outputPath);
|
|
33
|
+
logger.info(`正在备份上传文件 → ${outputPath}`);
|
|
34
|
+
try {
|
|
35
|
+
// 使用 alpine 容器备份卷内容
|
|
36
|
+
await execa('docker', [
|
|
37
|
+
'run', '--rm',
|
|
38
|
+
'-v', `${volumeName}:/data:ro`,
|
|
39
|
+
'-v', `${outputDir}:/backup`,
|
|
40
|
+
'alpine',
|
|
41
|
+
'tar', 'czf', `/backup/${outputFileName}`, '-C', '/data', '.'
|
|
42
|
+
], {
|
|
43
|
+
stdio: 'inherit'
|
|
44
|
+
});
|
|
45
|
+
// 检查输出文件
|
|
46
|
+
if (fs.existsSync(outputPath)) {
|
|
47
|
+
const stats = fs.statSync(outputPath);
|
|
48
|
+
logger.success(`上传文件备份成功!文件大小: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
logger.error('备份文件未生成');
|
|
52
|
+
this.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
logger.error('上传文件备份失败');
|
|
57
|
+
if (error instanceof Error) {
|
|
58
|
+
if (error.message.includes('No such volume')) {
|
|
59
|
+
logger.error(`数据卷 "${volumeName}" 不存在。请确保服务已启动过至少一次。`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
logger.error(error.message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 获取上传文件数据卷名称
|
|
70
|
+
* 优先级: 1. .env 中的 COMPOSE_PROJECT_NAME 2. 当前目录名 3. 自动搜索
|
|
71
|
+
*/
|
|
72
|
+
async getUploadsVolumeName() {
|
|
73
|
+
// 1. 优先从 .env 获取项目名称
|
|
74
|
+
const envConfig = dotenv.config().parsed || {};
|
|
75
|
+
if (envConfig.COMPOSE_PROJECT_NAME) {
|
|
76
|
+
const volumeName = `${envConfig.COMPOSE_PROJECT_NAME}_api_uploads`;
|
|
77
|
+
try {
|
|
78
|
+
await execa('docker', ['volume', 'inspect', volumeName]);
|
|
79
|
+
return volumeName;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// 卷不存在,继续尝试其他方式
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 2. 尝试使用当前目录名(与 Docker Compose 默认行为一致)
|
|
86
|
+
const dirName = path.basename(process.cwd());
|
|
87
|
+
const volumeByDir = `${dirName}_api_uploads`;
|
|
88
|
+
try {
|
|
89
|
+
await execa('docker', ['volume', 'inspect', volumeByDir]);
|
|
90
|
+
return volumeByDir;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// 卷不存在,继续
|
|
94
|
+
}
|
|
95
|
+
// 3. 自动搜索包含 api_uploads 的卷
|
|
96
|
+
try {
|
|
97
|
+
const result = await execa('docker', ['volume', 'ls', '--format', '{{.Name}}']);
|
|
98
|
+
const volumes = result.stdout.split('\n').filter(v => v.includes('api_uploads'));
|
|
99
|
+
if (volumes.length === 1) {
|
|
100
|
+
return volumes[0];
|
|
101
|
+
}
|
|
102
|
+
if (volumes.length > 1) {
|
|
103
|
+
// 多个匹配,提示用户
|
|
104
|
+
logger.warning('找到多个可能的上传卷:');
|
|
105
|
+
for (const v of volumes)
|
|
106
|
+
logger.info(` - ${v}`);
|
|
107
|
+
logger.info('请在 .env 中设置 COMPOSE_PROJECT_NAME 以指定正确的卷');
|
|
108
|
+
return volumes[0]; // 默认使用第一个
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// 忽略错误
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -4,9 +4,9 @@ export default class Clean extends Command {
|
|
|
4
4
|
static flags: {
|
|
5
5
|
all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
6
|
cache: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
|
-
images: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
7
|
env: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
8
|
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
images: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
10
|
};
|
|
11
11
|
run(): Promise<void>;
|
|
12
12
|
}
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
import { Command, Flags } from '@oclif/core';
|
|
2
1
|
import { checkbox, confirm } from '@inquirer/prompts';
|
|
3
|
-
import {
|
|
2
|
+
import { Command, Flags } from '@oclif/core';
|
|
4
3
|
import { execa } from 'execa';
|
|
4
|
+
import { logger } from '../../utils/logger.js';
|
|
5
5
|
import { clearStoredEnv } from '../../utils/selection.js';
|
|
6
6
|
export default class Clean extends Command {
|
|
7
7
|
static description = '清理 Docker 缓存和残留资源';
|
|
8
8
|
static flags = {
|
|
9
9
|
all: Flags.boolean({
|
|
10
10
|
char: 'a',
|
|
11
|
-
description: '清理所有 (构建缓存、无用镜像、网络、环境锁定)',
|
|
12
11
|
default: false,
|
|
12
|
+
description: '清理所有 (构建缓存、无用镜像、网络、环境锁定)',
|
|
13
13
|
}),
|
|
14
14
|
cache: Flags.boolean({
|
|
15
|
-
description: '清理构建缓存',
|
|
16
|
-
default: false,
|
|
17
|
-
}),
|
|
18
|
-
images: Flags.boolean({
|
|
19
|
-
description: '清理无用镜像 (dangling)',
|
|
20
15
|
default: false,
|
|
16
|
+
description: '清理构建缓存',
|
|
21
17
|
}),
|
|
22
18
|
env: Flags.boolean({
|
|
23
|
-
description: '清理环境锁定 (Environment Lock)',
|
|
24
19
|
default: false,
|
|
20
|
+
description: '清理环境锁定 (Environment Lock)',
|
|
25
21
|
}),
|
|
26
22
|
force: Flags.boolean({
|
|
27
23
|
char: 'f',
|
|
24
|
+
default: false,
|
|
28
25
|
description: '跳过确认提示',
|
|
26
|
+
}),
|
|
27
|
+
images: Flags.boolean({
|
|
29
28
|
default: false,
|
|
29
|
+
description: '清理无用镜像 (dangling)',
|
|
30
30
|
}),
|
|
31
31
|
};
|
|
32
32
|
async run() {
|
|
@@ -47,13 +47,13 @@ export default class Clean extends Command {
|
|
|
47
47
|
// 如果未提供标志,进行交互式选择
|
|
48
48
|
if (targets.length === 0) {
|
|
49
49
|
targets = await checkbox({
|
|
50
|
-
message: '请选择要清理的项目:',
|
|
51
50
|
choices: [
|
|
52
51
|
{ name: '构建缓存 (Build Cache)', value: 'cache' },
|
|
53
52
|
{ name: '无用镜像 (Dangling Images)', value: 'images' },
|
|
54
53
|
{ name: '无用网络 (Unused Networks)', value: 'networks' },
|
|
55
54
|
{ name: '环境锁定 (Environment Lock)', value: 'env' },
|
|
56
55
|
],
|
|
56
|
+
message: '请选择要清理的项目:',
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
if (targets.length === 0) {
|
|
@@ -64,8 +64,8 @@ export default class Clean extends Command {
|
|
|
64
64
|
if (!flags.force) {
|
|
65
65
|
logger.warning(`即将清理: ${targets.join(', ')}`);
|
|
66
66
|
const confirmed = await confirm({
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
default: false,
|
|
68
|
+
message: '确认继续?'
|
|
69
69
|
});
|
|
70
70
|
if (!confirmed) {
|
|
71
71
|
logger.info('操作已取消。');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Command } from '@oclif/core';
|
|
2
1
|
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Command } from '@oclif/core';
|
|
3
3
|
import { execCompose, getComposeFiles } from '../../utils/docker.js';
|
|
4
4
|
import { logger } from '../../utils/logger.js';
|
|
5
5
|
import { EnvFlag, selectEnvironment } from '../../utils/selection.js';
|
|
@@ -12,10 +12,10 @@ export default class DbReset extends Command {
|
|
|
12
12
|
const { flags } = await this.parse(DbReset);
|
|
13
13
|
const env = await selectEnvironment(flags.env);
|
|
14
14
|
const { files, isBuiltIn } = await getComposeFiles(env);
|
|
15
|
-
logger.warning('
|
|
15
|
+
logger.warning('这将清除所有数据!');
|
|
16
16
|
const confirmReset = await confirm({
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
default: false,
|
|
18
|
+
message: '确认继续?'
|
|
19
19
|
});
|
|
20
20
|
if (!confirmReset) {
|
|
21
21
|
logger.info('操作已取消。');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class ImportAll extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static flags: {
|
|
5
|
+
dir: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
6
|
+
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
};
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { confirm, input, select } from '@inquirer/prompts';
|
|
2
|
+
import { Command, Flags } from '@oclif/core';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { logger } from '../../utils/logger.js';
|
|
6
|
+
import ImportDb from './db.js';
|
|
7
|
+
import ImportUploads from './uploads.js';
|
|
8
|
+
export default class ImportAll extends Command {
|
|
9
|
+
static description = '一键恢复全部数据';
|
|
10
|
+
static flags = {
|
|
11
|
+
dir: Flags.string({
|
|
12
|
+
char: 'd',
|
|
13
|
+
description: '备份目录路径 (包含 .sql 和 .tar.gz 文件)',
|
|
14
|
+
}),
|
|
15
|
+
env: Flags.string({
|
|
16
|
+
char: 'e',
|
|
17
|
+
description: '运行环境 (production, lowmem, default)',
|
|
18
|
+
options: ['production', 'lowmem', 'default'],
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
async run() {
|
|
22
|
+
const { flags } = await this.parse(ImportAll);
|
|
23
|
+
// 确定备份目录
|
|
24
|
+
let backupDir = flags.dir;
|
|
25
|
+
if (!backupDir) {
|
|
26
|
+
// 尝试查找当前目录下的备份目录
|
|
27
|
+
const dirs = fs.readdirSync(process.cwd())
|
|
28
|
+
.filter(f => {
|
|
29
|
+
const fullPath = path.join(process.cwd(), f);
|
|
30
|
+
return fs.statSync(fullPath).isDirectory() && f.startsWith('backup_');
|
|
31
|
+
})
|
|
32
|
+
.sort((a, b) => b.localeCompare(a)); // 按名称倒序(最新的在前)
|
|
33
|
+
if (dirs.length > 0) {
|
|
34
|
+
const choices = dirs.map(d => ({ name: d, value: d }));
|
|
35
|
+
choices.push({ name: '手动输入路径', value: '__MANUAL__' });
|
|
36
|
+
const selection = await select({
|
|
37
|
+
choices,
|
|
38
|
+
message: '请选择备份目录:',
|
|
39
|
+
});
|
|
40
|
+
backupDir = selection === '__MANUAL__' ? (await input({ message: '请输入备份目录路径:' })) : selection;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
backupDir = await input({ message: '未找到备份目录,请输入路径:' });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// 验证备份目录
|
|
47
|
+
const backupPath = path.resolve(process.cwd(), backupDir);
|
|
48
|
+
if (!fs.existsSync(backupPath) || !fs.statSync(backupPath).isDirectory()) {
|
|
49
|
+
logger.error(`目录不存在或不是有效目录: ${backupPath}`);
|
|
50
|
+
this.exit(1);
|
|
51
|
+
}
|
|
52
|
+
// 查找备份文件
|
|
53
|
+
const files = fs.readdirSync(backupPath);
|
|
54
|
+
const sqlFile = files.find(f => f.endsWith('.sql'));
|
|
55
|
+
const uploadsFile = files.find(f => f.endsWith('.tar.gz'));
|
|
56
|
+
if (!sqlFile && !uploadsFile) {
|
|
57
|
+
logger.error('备份目录中未找到有效的备份文件 (.sql 或 .tar.gz)');
|
|
58
|
+
this.exit(1);
|
|
59
|
+
}
|
|
60
|
+
logger.info(`备份目录: ${backupPath}`);
|
|
61
|
+
logger.info('找到以下备份文件:');
|
|
62
|
+
if (sqlFile)
|
|
63
|
+
logger.info(` - 数据库: ${sqlFile}`);
|
|
64
|
+
if (uploadsFile)
|
|
65
|
+
logger.info(` - 上传文件: ${uploadsFile}`);
|
|
66
|
+
// 最终确认
|
|
67
|
+
logger.warning('此操作将清空当前所有数据并从备份恢复!');
|
|
68
|
+
const isConfirmed = await confirm({
|
|
69
|
+
default: false,
|
|
70
|
+
message: '确认继续完整恢复?(数据丢失不可撤销)'
|
|
71
|
+
});
|
|
72
|
+
if (!isConfirmed) {
|
|
73
|
+
logger.info('操作已取消');
|
|
74
|
+
this.exit(0);
|
|
75
|
+
}
|
|
76
|
+
logger.info('\n开始完整恢复...');
|
|
77
|
+
logger.info('='.repeat(50));
|
|
78
|
+
// 1. 恢复数据库
|
|
79
|
+
if (sqlFile) {
|
|
80
|
+
logger.info('\n[1/2] 恢复数据库...');
|
|
81
|
+
try {
|
|
82
|
+
const dbArgs = ['-i', path.join(backupPath, sqlFile)];
|
|
83
|
+
if (flags.env) {
|
|
84
|
+
dbArgs.push('-e', flags.env);
|
|
85
|
+
}
|
|
86
|
+
// 设置环境变量跳过确认(因为上面已确认)
|
|
87
|
+
process.env.NODEBBS_SKIP_CONFIRM = 'true';
|
|
88
|
+
await ImportDb.run(dbArgs);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
logger.error('数据库恢复失败');
|
|
92
|
+
this.exit(1);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
delete process.env.NODEBBS_SKIP_CONFIRM;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
logger.info('\n[1/2] 跳过数据库恢复 (未找到 .sql 文件)');
|
|
100
|
+
}
|
|
101
|
+
// 2. 恢复上传文件
|
|
102
|
+
if (uploadsFile) {
|
|
103
|
+
logger.info('\n[2/2] 恢复上传文件...');
|
|
104
|
+
try {
|
|
105
|
+
process.env.NODEBBS_SKIP_CONFIRM = 'true';
|
|
106
|
+
await ImportUploads.run(['-i', path.join(backupPath, uploadsFile)]);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
logger.warning('上传文件恢复失败');
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
delete process.env.NODEBBS_SKIP_CONFIRM;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
logger.info('\n[2/2] 跳过上传文件恢复 (未找到 .tar.gz 文件)');
|
|
117
|
+
}
|
|
118
|
+
logger.info('\n' + '='.repeat(50));
|
|
119
|
+
logger.success('完整恢复完成!');
|
|
120
|
+
logger.info('\n请重启服务以确保更改生效:');
|
|
121
|
+
logger.info(' nodebbs restart');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
|
-
export default class
|
|
2
|
+
export default class ImportDb extends Command {
|
|
3
3
|
static description: string;
|
|
4
4
|
static flags: {
|
|
5
5
|
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
import { confirm, input, select } from '@inquirer/prompts';
|
|
1
2
|
import { Command, Flags } from '@oclif/core';
|
|
2
|
-
import
|
|
3
|
-
import { logger } from '../../utils/logger.js';
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
4
|
import { execa } from 'execa';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import
|
|
7
|
+
import { getComposeFiles } from '../../utils/docker.js';
|
|
8
|
+
import { logger } from '../../utils/logger.js';
|
|
8
9
|
import { EnvFlag, selectEnvironment } from '../../utils/selection.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
static description = '导入数据库 (从 SQL 文件恢复)';
|
|
10
|
+
export default class ImportDb extends Command {
|
|
11
|
+
static description = '导入数据库';
|
|
12
12
|
static flags = {
|
|
13
13
|
env: EnvFlag,
|
|
14
14
|
input: Flags.string({
|
|
@@ -17,7 +17,7 @@ export default class DbImport extends Command {
|
|
|
17
17
|
}),
|
|
18
18
|
};
|
|
19
19
|
async run() {
|
|
20
|
-
const { flags } = await this.parse(
|
|
20
|
+
const { flags } = await this.parse(ImportDb);
|
|
21
21
|
// 1. 选择环境
|
|
22
22
|
const env = await selectEnvironment(flags.env);
|
|
23
23
|
// 2. 确定输入文件
|
|
@@ -27,7 +27,7 @@ export default class DbImport extends Command {
|
|
|
27
27
|
const files = fs.readdirSync(process.cwd())
|
|
28
28
|
.filter(f => f.endsWith('.sql'))
|
|
29
29
|
.sort((a, b) => {
|
|
30
|
-
//
|
|
30
|
+
// 按文件修改时间倒序
|
|
31
31
|
const statA = fs.statSync(a);
|
|
32
32
|
const statB = fs.statSync(b);
|
|
33
33
|
return statB.mtime.getTime() - statA.mtime.getTime();
|
|
@@ -39,15 +39,10 @@ export default class DbImport extends Command {
|
|
|
39
39
|
}));
|
|
40
40
|
choices.push({ name: '手动输入路径', value: '__MANUAL__' });
|
|
41
41
|
const selection = await select({
|
|
42
|
+
choices,
|
|
42
43
|
message: '请选择导入文件:',
|
|
43
|
-
choices: choices,
|
|
44
44
|
});
|
|
45
|
-
|
|
46
|
-
inputFile = await input({ message: '请输入文件路径:' });
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
inputFile = selection;
|
|
50
|
-
}
|
|
45
|
+
inputFile = selection === '__MANUAL__' ? (await input({ message: '请输入文件路径:' })) : selection;
|
|
51
46
|
}
|
|
52
47
|
else {
|
|
53
48
|
inputFile = await input({ message: '未找到 SQL 文件,请输入路径:' });
|
|
@@ -60,11 +55,11 @@ export default class DbImport extends Command {
|
|
|
60
55
|
this.exit(1);
|
|
61
56
|
}
|
|
62
57
|
// 3. 危险操作确认
|
|
63
|
-
logger.warning('
|
|
58
|
+
logger.warning('此操作将清空当前数据库的所有数据并从备份恢复!');
|
|
64
59
|
logger.warning(`目标文件: ${inputPath}`);
|
|
65
60
|
const isConfirmed = await confirm({
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
default: false,
|
|
62
|
+
message: '确认继续?(数据丢失不可撤销)'
|
|
68
63
|
});
|
|
69
64
|
if (!isConfirmed) {
|
|
70
65
|
logger.info('操作已取消');
|
|
@@ -76,7 +71,7 @@ export default class DbImport extends Command {
|
|
|
76
71
|
if (isBuiltIn) {
|
|
77
72
|
composeArgs.push('--project-directory', process.cwd());
|
|
78
73
|
}
|
|
79
|
-
//
|
|
74
|
+
// 加载环境配置
|
|
80
75
|
const envConfig = dotenv.config().parsed || {};
|
|
81
76
|
const dbUser = envConfig.POSTGRES_USER || 'postgres';
|
|
82
77
|
const dbName = envConfig.POSTGRES_DB || 'nodebbs';
|
|
@@ -84,8 +79,7 @@ export default class DbImport extends Command {
|
|
|
84
79
|
// 5. 执行恢复
|
|
85
80
|
logger.info('正在准备恢复数据库...');
|
|
86
81
|
try {
|
|
87
|
-
// 步骤 A: 重置 Schema
|
|
88
|
-
// 这样做比单纯导入更干净,因为 pg_dump 默认可能不包含 DROP TABLE
|
|
82
|
+
// 步骤 A: 重置 Schema
|
|
89
83
|
logger.info('正在重置数据库 Schema...');
|
|
90
84
|
const resetCmd = [...composeArgs, 'exec', '-T', 'postgres', 'psql', '-U', dbUser, '-d', dbName, '-c', 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'];
|
|
91
85
|
// 如果有密码
|
|
@@ -95,7 +89,7 @@ export default class DbImport extends Command {
|
|
|
95
89
|
await execa('docker', ['compose', ...resetCmd], { env: envs });
|
|
96
90
|
logger.success('数据库 Schema 已重置');
|
|
97
91
|
// 步骤 B: 导入数据
|
|
98
|
-
logger.info(
|
|
92
|
+
logger.info(`正在从 ${path.basename(inputPath)} 导入数据...`);
|
|
99
93
|
const importCmd = [...composeArgs, 'exec', '-T', 'postgres', 'psql', '-U', dbUser, '-d', dbName];
|
|
100
94
|
const subprocess = execa('docker', ['compose', ...importCmd], {
|
|
101
95
|
env: envs,
|