nodebbs 0.3.3 → 0.4.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 +9 -6
- 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} +3 -3
- package/dist/commands/backup/uploads.d.ts +13 -0
- package/dist/commands/backup/uploads.js +117 -0
- package/dist/commands/import/all.d.ts +9 -0
- package/dist/commands/import/all.js +128 -0
- package/dist/commands/{db/import.d.ts → import/db.d.ts} +1 -1
- package/dist/commands/{db/import.js → import/db.js} +5 -6
- package/dist/commands/import/uploads.d.ts +13 -0
- package/dist/commands/import/uploads.js +146 -0
- package/dist/interactive.js +3 -1
- package/dist/templates/config.yml +22 -0
- package/dist/templates/docker-compose.yml +1 -5
- package/dist/templates/env +0 -3
- package/dist/utils/docker.js +6 -0
- package/oclif.manifest.json +186 -40
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -77,28 +77,31 @@ $ nodebbs
|
|
|
77
77
|
upgrade 升级服务
|
|
78
78
|
status 查看服务状态
|
|
79
79
|
logs 查看服务日志 [+]
|
|
80
|
+
db 数据库操作 (种子数据, 重置等) [+]
|
|
81
|
+
backup 备份数据 (数据库, 上传文件等) [+]
|
|
82
|
+
import 导入/恢复数据 [+]
|
|
83
|
+
clean 清理 Docker 缓存和残留资源
|
|
80
84
|
shell 进入容器终端 [+]
|
|
81
|
-
db 数据库操作 (备份, 迁移, 种子数据等) [+]
|
|
82
85
|
pack 生成离线部署包
|
|
83
|
-
clean 清理 Docker 缓存和残留资源
|
|
84
|
-
help 显示帮助信息
|
|
85
86
|
❌ 退出
|
|
86
87
|
```
|
|
87
88
|
|
|
88
89
|
#### 命令说明
|
|
89
90
|
|
|
90
91
|
| 命令 | 说明 |
|
|
91
|
-
|------|---------|
|
|
92
|
+
|------|---------|
|
|
92
93
|
| **start** | 启动服务(根据当前配置环境)|
|
|
93
94
|
| **stop** | 停止所有服务 |
|
|
94
95
|
| **restart** | 重启服务(相当于 `docker compose up --force-recreate`)|
|
|
95
96
|
| **upgrade** | 升级服务(拉取最新 Docker 镜像或重新构建本地镜像)|
|
|
96
97
|
| **status** | 查看所有容器的运行状态和健康检查结果 |
|
|
97
98
|
| **logs** | 查看服务日志(支持选择特定服务 API/Web/DB/Redis)|
|
|
98
|
-
| **
|
|
99
|
-
| **
|
|
99
|
+
| **db** | 数据库操作:种子数据 (seed)、重置 (reset) 等 |
|
|
100
|
+
| **backup** | 备份数据:<br>• 数据库 (db)<br>• 上传文件 (uploads)<br>• 一键全部备份 (all) |
|
|
101
|
+
| **import** | 恢复数据:<br>• 数据库 (db)<br>• 上传文件 (uploads)<br>• 一键全部恢复 (all) |
|
|
100
102
|
| **pack** | 生成离线部署包(**仅限源码模式**),方便在无网环境部署 |
|
|
101
103
|
| **clean** | 清理工具(删除未使用镜像、容器、卷,释放磁盘空间)|
|
|
104
|
+
| **shell** | 进入容器终端进行调试(支持选择特定服务)|
|
|
102
105
|
|
|
103
106
|
### 离线服务器部署
|
|
104
107
|
|
|
@@ -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
|
+
output: 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,71 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
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
|
+
output: Flags.string({
|
|
11
|
+
char: 'o',
|
|
12
|
+
description: '输出目录路径',
|
|
13
|
+
}),
|
|
14
|
+
env: Flags.string({
|
|
15
|
+
char: 'e',
|
|
16
|
+
description: '运行环境 (production, lowmem, default)',
|
|
17
|
+
options: ['production', 'lowmem', 'default'],
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
async run() {
|
|
21
|
+
const { flags } = await this.parse(BackupAll);
|
|
22
|
+
// 生成备份目录
|
|
23
|
+
const timestamp = new Date().toISOString().replace(/[:.]/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 (error) {
|
|
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 (error) {
|
|
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>;
|
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import dotenv from 'dotenv';
|
|
8
8
|
import { EnvFlag, selectEnvironment } from '../../utils/selection.js';
|
|
9
|
-
export default class
|
|
9
|
+
export default class BackupDb extends Command {
|
|
10
10
|
static description = '备份数据库 (PostgreSQL)';
|
|
11
11
|
static flags = {
|
|
12
12
|
env: EnvFlag,
|
|
@@ -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
25
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
26
|
-
outputFile = `
|
|
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,117 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import dotenv from 'dotenv';
|
|
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().replace(/[:.]/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(`正在备份上传文件...`);
|
|
34
|
+
logger.info(`数据卷: ${volumeName}`);
|
|
35
|
+
logger.info(`输出文件: ${outputPath}`);
|
|
36
|
+
try {
|
|
37
|
+
// 使用 alpine 容器备份卷内容
|
|
38
|
+
await execa('docker', [
|
|
39
|
+
'run', '--rm',
|
|
40
|
+
'-v', `${volumeName}:/data:ro`,
|
|
41
|
+
'-v', `${outputDir}:/backup`,
|
|
42
|
+
'alpine',
|
|
43
|
+
'tar', 'czf', `/backup/${outputFileName}`, '-C', '/data', '.'
|
|
44
|
+
], {
|
|
45
|
+
stdio: 'inherit'
|
|
46
|
+
});
|
|
47
|
+
// 检查输出文件
|
|
48
|
+
if (fs.existsSync(outputPath)) {
|
|
49
|
+
const stats = fs.statSync(outputPath);
|
|
50
|
+
logger.success(`上传文件备份成功!文件大小: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
logger.error('备份文件未生成');
|
|
54
|
+
this.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
logger.error('上传文件备份失败');
|
|
59
|
+
if (error instanceof Error) {
|
|
60
|
+
if (error.message.includes('No such volume')) {
|
|
61
|
+
logger.error(`数据卷 "${volumeName}" 不存在。请确保服务已启动过至少一次。`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
logger.error(error.message);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.exit(1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 获取上传文件数据卷名称
|
|
72
|
+
* 优先级: 1. .env 中的 COMPOSE_PROJECT_NAME 2. 当前目录名 3. 自动搜索
|
|
73
|
+
*/
|
|
74
|
+
async getUploadsVolumeName() {
|
|
75
|
+
// 1. 优先从 .env 获取项目名称
|
|
76
|
+
const envConfig = dotenv.config().parsed || {};
|
|
77
|
+
if (envConfig.COMPOSE_PROJECT_NAME) {
|
|
78
|
+
const volumeName = `${envConfig.COMPOSE_PROJECT_NAME}_api_uploads`;
|
|
79
|
+
try {
|
|
80
|
+
await execa('docker', ['volume', 'inspect', volumeName]);
|
|
81
|
+
return volumeName;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// 卷不存在,继续尝试其他方式
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// 2. 尝试使用当前目录名(与 Docker Compose 默认行为一致)
|
|
88
|
+
const dirName = path.basename(process.cwd());
|
|
89
|
+
const volumeByDir = `${dirName}_api_uploads`;
|
|
90
|
+
try {
|
|
91
|
+
await execa('docker', ['volume', 'inspect', volumeByDir]);
|
|
92
|
+
return volumeByDir;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// 卷不存在,继续
|
|
96
|
+
}
|
|
97
|
+
// 3. 自动搜索包含 api_uploads 的卷
|
|
98
|
+
try {
|
|
99
|
+
const result = await execa('docker', ['volume', 'ls', '--format', '{{.Name}}']);
|
|
100
|
+
const volumes = result.stdout.split('\n').filter(v => v.includes('api_uploads'));
|
|
101
|
+
if (volumes.length === 1) {
|
|
102
|
+
return volumes[0];
|
|
103
|
+
}
|
|
104
|
+
else if (volumes.length > 1) {
|
|
105
|
+
// 多个匹配,提示用户
|
|
106
|
+
logger.warning('找到多个可能的上传卷:');
|
|
107
|
+
volumes.forEach(v => logger.info(` - ${v}`));
|
|
108
|
+
logger.info('请在 .env 中设置 COMPOSE_PROJECT_NAME 以指定正确的卷');
|
|
109
|
+
return volumes[0]; // 默认使用第一个
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// 忽略错误
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -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,128 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { select, input, confirm } from '@inquirer/prompts';
|
|
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
|
+
message: '请选择备份目录:',
|
|
38
|
+
choices: choices,
|
|
39
|
+
});
|
|
40
|
+
if (selection === '__MANUAL__') {
|
|
41
|
+
backupDir = await input({ message: '请输入备份目录路径:' });
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
backupDir = selection;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
backupDir = await input({ message: '未找到备份目录,请输入路径:' });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// 验证备份目录
|
|
52
|
+
const backupPath = path.resolve(process.cwd(), backupDir);
|
|
53
|
+
if (!fs.existsSync(backupPath) || !fs.statSync(backupPath).isDirectory()) {
|
|
54
|
+
logger.error(`目录不存在或不是有效目录: ${backupPath}`);
|
|
55
|
+
this.exit(1);
|
|
56
|
+
}
|
|
57
|
+
// 查找备份文件
|
|
58
|
+
const files = fs.readdirSync(backupPath);
|
|
59
|
+
const sqlFile = files.find(f => f.endsWith('.sql'));
|
|
60
|
+
const uploadsFile = files.find(f => f.endsWith('.tar.gz'));
|
|
61
|
+
if (!sqlFile && !uploadsFile) {
|
|
62
|
+
logger.error('备份目录中未找到有效的备份文件 (.sql 或 .tar.gz)');
|
|
63
|
+
this.exit(1);
|
|
64
|
+
}
|
|
65
|
+
logger.info(`备份目录: ${backupPath}`);
|
|
66
|
+
logger.info('找到以下备份文件:');
|
|
67
|
+
if (sqlFile)
|
|
68
|
+
logger.info(` - 数据库: ${sqlFile}`);
|
|
69
|
+
if (uploadsFile)
|
|
70
|
+
logger.info(` - 上传文件: ${uploadsFile}`);
|
|
71
|
+
// 最终确认
|
|
72
|
+
logger.warning('\n警告: 此操作将清空当前所有数据并从备份恢复!');
|
|
73
|
+
const isConfirmed = await confirm({
|
|
74
|
+
message: '确认继续完整恢复?(数据丢失不可撤销)',
|
|
75
|
+
default: false
|
|
76
|
+
});
|
|
77
|
+
if (!isConfirmed) {
|
|
78
|
+
logger.info('操作已取消');
|
|
79
|
+
this.exit(0);
|
|
80
|
+
}
|
|
81
|
+
logger.info('\n开始完整恢复...');
|
|
82
|
+
logger.info('='.repeat(50));
|
|
83
|
+
// 1. 恢复数据库
|
|
84
|
+
if (sqlFile) {
|
|
85
|
+
logger.info('\n[1/2] 恢复数据库...');
|
|
86
|
+
try {
|
|
87
|
+
const dbArgs = ['-i', path.join(backupPath, sqlFile)];
|
|
88
|
+
if (flags.env) {
|
|
89
|
+
dbArgs.push('-e', flags.env);
|
|
90
|
+
}
|
|
91
|
+
// 设置环境变量跳过确认(因为上面已确认)
|
|
92
|
+
process.env.NODEBBS_SKIP_CONFIRM = 'true';
|
|
93
|
+
await ImportDb.run(dbArgs);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
logger.error('数据库恢复失败');
|
|
97
|
+
this.exit(1);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
delete process.env.NODEBBS_SKIP_CONFIRM;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
logger.info('\n[1/2] 跳过数据库恢复 (未找到 .sql 文件)');
|
|
105
|
+
}
|
|
106
|
+
// 2. 恢复上传文件
|
|
107
|
+
if (uploadsFile) {
|
|
108
|
+
logger.info('\n[2/2] 恢复上传文件...');
|
|
109
|
+
try {
|
|
110
|
+
process.env.NODEBBS_SKIP_CONFIRM = 'true';
|
|
111
|
+
await ImportUploads.run(['-i', path.join(backupPath, uploadsFile)]);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
logger.warning('上传文件恢复失败');
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
delete process.env.NODEBBS_SKIP_CONFIRM;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
logger.info('\n[2/2] 跳过上传文件恢复 (未找到 .tar.gz 文件)');
|
|
122
|
+
}
|
|
123
|
+
logger.info('\n' + '='.repeat(50));
|
|
124
|
+
logger.success('完整恢复完成!');
|
|
125
|
+
logger.info('\n请重启服务以确保更改生效:');
|
|
126
|
+
logger.info(' nodebbs restart');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -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>;
|
|
@@ -7,7 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import dotenv from 'dotenv';
|
|
8
8
|
import { EnvFlag, selectEnvironment } from '../../utils/selection.js';
|
|
9
9
|
import { confirm, select, input } from '@inquirer/prompts';
|
|
10
|
-
export default class
|
|
10
|
+
export default class ImportDb extends Command {
|
|
11
11
|
static description = '导入数据库 (从 SQL 文件恢复)';
|
|
12
12
|
static flags = {
|
|
13
13
|
env: EnvFlag,
|
|
@@ -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();
|
|
@@ -76,7 +76,7 @@ export default class DbImport extends Command {
|
|
|
76
76
|
if (isBuiltIn) {
|
|
77
77
|
composeArgs.push('--project-directory', process.cwd());
|
|
78
78
|
}
|
|
79
|
-
//
|
|
79
|
+
// 加载环境配置
|
|
80
80
|
const envConfig = dotenv.config().parsed || {};
|
|
81
81
|
const dbUser = envConfig.POSTGRES_USER || 'postgres';
|
|
82
82
|
const dbName = envConfig.POSTGRES_DB || 'nodebbs';
|
|
@@ -84,8 +84,7 @@ export default class DbImport extends Command {
|
|
|
84
84
|
// 5. 执行恢复
|
|
85
85
|
logger.info('正在准备恢复数据库...');
|
|
86
86
|
try {
|
|
87
|
-
// 步骤 A: 重置 Schema
|
|
88
|
-
// 这样做比单纯导入更干净,因为 pg_dump 默认可能不包含 DROP TABLE
|
|
87
|
+
// 步骤 A: 重置 Schema
|
|
89
88
|
logger.info('正在重置数据库 Schema...');
|
|
90
89
|
const resetCmd = [...composeArgs, 'exec', '-T', 'postgres', 'psql', '-U', dbUser, '-d', dbName, '-c', 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'];
|
|
91
90
|
// 如果有密码
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class ImportUploads extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static flags: {
|
|
5
|
+
input: 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. 自动搜索 4. 创建新卷
|
|
11
|
+
*/
|
|
12
|
+
private getOrCreateUploadsVolume;
|
|
13
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import dotenv from 'dotenv';
|
|
7
|
+
import { confirm, select, input } from '@inquirer/prompts';
|
|
8
|
+
export default class ImportUploads extends Command {
|
|
9
|
+
static description = '恢复上传文件 (从 tar.gz 备份恢复)';
|
|
10
|
+
static flags = {
|
|
11
|
+
input: Flags.string({
|
|
12
|
+
char: 'i',
|
|
13
|
+
description: '输入文件路径 (.tar.gz)',
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
async run() {
|
|
17
|
+
const { flags } = await this.parse(ImportUploads);
|
|
18
|
+
// 确定输入文件
|
|
19
|
+
let inputFile = flags.input;
|
|
20
|
+
if (!inputFile) {
|
|
21
|
+
// 尝试自动查找当前目录下的 .tar.gz 文件
|
|
22
|
+
const files = fs.readdirSync(process.cwd())
|
|
23
|
+
.filter(f => f.endsWith('.tar.gz') && f.includes('uploads'))
|
|
24
|
+
.sort((a, b) => {
|
|
25
|
+
const statA = fs.statSync(a);
|
|
26
|
+
const statB = fs.statSync(b);
|
|
27
|
+
return statB.mtime.getTime() - statA.mtime.getTime();
|
|
28
|
+
});
|
|
29
|
+
if (files.length > 0) {
|
|
30
|
+
const choices = files.map(f => ({
|
|
31
|
+
name: `${f} (${(fs.statSync(f).size / 1024 / 1024).toFixed(2)} MB)`,
|
|
32
|
+
value: f
|
|
33
|
+
}));
|
|
34
|
+
choices.push({ name: '手动输入路径', value: '__MANUAL__' });
|
|
35
|
+
const selection = await select({
|
|
36
|
+
message: '请选择上传文件备份:',
|
|
37
|
+
choices: choices,
|
|
38
|
+
});
|
|
39
|
+
if (selection === '__MANUAL__') {
|
|
40
|
+
inputFile = await input({ message: '请输入文件路径:' });
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
inputFile = selection;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
inputFile = await input({ message: '未找到上传备份文件 (.tar.gz),请输入路径:' });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 确保绝对路径并检查是否存在
|
|
51
|
+
const inputPath = path.resolve(process.cwd(), inputFile);
|
|
52
|
+
if (!fs.existsSync(inputPath)) {
|
|
53
|
+
logger.error(`文件未找到: ${inputPath}`);
|
|
54
|
+
this.exit(1);
|
|
55
|
+
}
|
|
56
|
+
// 获取或创建上传文件卷
|
|
57
|
+
const volumeName = await this.getOrCreateUploadsVolume();
|
|
58
|
+
// 危险操作确认
|
|
59
|
+
if (process.env.NODEBBS_SKIP_CONFIRM !== 'true') {
|
|
60
|
+
logger.warning('警告: 此操作将清空当前上传目录的所有文件并从备份恢复!');
|
|
61
|
+
logger.warning(`目标文件: ${inputPath}`);
|
|
62
|
+
logger.warning(`数据卷: ${volumeName}`);
|
|
63
|
+
const isConfirmed = await confirm({
|
|
64
|
+
message: '确认继续?(文件丢失不可撤销)',
|
|
65
|
+
default: false
|
|
66
|
+
});
|
|
67
|
+
if (!isConfirmed) {
|
|
68
|
+
logger.info('操作已取消');
|
|
69
|
+
this.exit(0);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
logger.info('正在恢复上传文件...');
|
|
73
|
+
logger.info(`数据卷: ${volumeName}`);
|
|
74
|
+
const inputDir = path.dirname(inputPath);
|
|
75
|
+
const inputFileName = path.basename(inputPath);
|
|
76
|
+
try {
|
|
77
|
+
// 使用 alpine 容器恢复卷内容
|
|
78
|
+
await execa('docker', [
|
|
79
|
+
'run', '--rm',
|
|
80
|
+
'-v', `${volumeName}:/data`,
|
|
81
|
+
'-v', `${inputDir}:/backup:ro`,
|
|
82
|
+
'alpine',
|
|
83
|
+
'sh', '-c', `rm -rf /data/* && tar xzf /backup/${inputFileName} -C /data`
|
|
84
|
+
], {
|
|
85
|
+
stdio: 'inherit'
|
|
86
|
+
});
|
|
87
|
+
logger.success('上传文件恢复成功!');
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
logger.error('上传文件恢复失败');
|
|
91
|
+
if (error instanceof Error) {
|
|
92
|
+
logger.error(error.message);
|
|
93
|
+
}
|
|
94
|
+
this.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 获取或创建上传文件数据卷
|
|
99
|
+
* 优先级: 1. .env 中的 COMPOSE_PROJECT_NAME 2. 当前目录名 3. 自动搜索 4. 创建新卷
|
|
100
|
+
*/
|
|
101
|
+
async getOrCreateUploadsVolume() {
|
|
102
|
+
// 1. 优先从 .env 获取项目名称
|
|
103
|
+
const envConfig = dotenv.config().parsed || {};
|
|
104
|
+
if (envConfig.COMPOSE_PROJECT_NAME) {
|
|
105
|
+
const volumeName = `${envConfig.COMPOSE_PROJECT_NAME}_api_uploads`;
|
|
106
|
+
try {
|
|
107
|
+
await execa('docker', ['volume', 'inspect', volumeName]);
|
|
108
|
+
return volumeName;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// 卷不存在,继续
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// 2. 尝试使用当前目录名(与 Docker Compose 默认行为一致)
|
|
115
|
+
const dirName = path.basename(process.cwd());
|
|
116
|
+
const volumeByDir = `${dirName}_api_uploads`;
|
|
117
|
+
try {
|
|
118
|
+
await execa('docker', ['volume', 'inspect', volumeByDir]);
|
|
119
|
+
return volumeByDir;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// 卷不存在,继续
|
|
123
|
+
}
|
|
124
|
+
// 3. 自动搜索包含 api_uploads 的卷
|
|
125
|
+
try {
|
|
126
|
+
const result = await execa('docker', ['volume', 'ls', '--format', '{{.Name}}']);
|
|
127
|
+
const volumes = result.stdout.split('\n').filter(v => v.includes('api_uploads'));
|
|
128
|
+
if (volumes.length === 1) {
|
|
129
|
+
return volumes[0];
|
|
130
|
+
}
|
|
131
|
+
else if (volumes.length > 1) {
|
|
132
|
+
logger.warning('找到多个可能的上传卷:');
|
|
133
|
+
volumes.forEach(v => logger.info(` - ${v}`));
|
|
134
|
+
logger.info('请在 .env 中设置 COMPOSE_PROJECT_NAME 以指定正确的卷');
|
|
135
|
+
return volumes[0];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// 忽略错误
|
|
140
|
+
}
|
|
141
|
+
// 4. 基于目录名创建新卷
|
|
142
|
+
logger.info(`数据卷 "${volumeByDir}" 不存在,正在创建...`);
|
|
143
|
+
await execa('docker', ['volume', 'create', volumeByDir]);
|
|
144
|
+
return volumeByDir;
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/interactive.js
CHANGED
|
@@ -29,9 +29,11 @@ export async function runInteractive(root) {
|
|
|
29
29
|
}
|
|
30
30
|
await navigate(tree, [], config);
|
|
31
31
|
}
|
|
32
|
-
const GLOBAL_PRIORITY = ['start', 'stop', 'restart', 'upgrade', 'status', 'logs', '
|
|
32
|
+
const GLOBAL_PRIORITY = ['start', 'stop', 'restart', 'upgrade', 'status', 'logs', 'db', 'backup', 'import', 'clean', 'shell', 'pack'];
|
|
33
33
|
const SCOPED_PRIORITIES = {
|
|
34
34
|
'logs': ['all', 'web', 'api', 'db', 'redis'],
|
|
35
|
+
'backup': ['all', 'db', 'uploads'],
|
|
36
|
+
'import': ['all', 'db', 'uploads'],
|
|
35
37
|
};
|
|
36
38
|
async function navigate(node, breadcrumbs, config) {
|
|
37
39
|
const currentPath = breadcrumbs.join(':');
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# NodeBBS 自定义配置
|
|
2
|
+
# 此文件用于挂载自定义静态资源(如 logo、favicon 等)
|
|
3
|
+
# 将此文件复制到项目根目录并取消需要的注释即可生效
|
|
4
|
+
#
|
|
5
|
+
# 使用方法:
|
|
6
|
+
# 1. 复制此文件到项目根目录:cp config.yml ./
|
|
7
|
+
# 2. 在项目根目录创建 web 文件夹:mkdir -p web
|
|
8
|
+
# 3. 将自定义资源放入 web 文件夹
|
|
9
|
+
# 4. 取消下方对应行的注释
|
|
10
|
+
# 5. 重启服务:npx nodebbs restart
|
|
11
|
+
|
|
12
|
+
services:
|
|
13
|
+
web:
|
|
14
|
+
volumes:
|
|
15
|
+
# 网站图标
|
|
16
|
+
- ./web/favicon.ico:/app/apps/web/public/favicon.ico
|
|
17
|
+
|
|
18
|
+
# 网站 Logo
|
|
19
|
+
- ./web/logo.svg:/app/apps/web/public/logo.svg
|
|
20
|
+
|
|
21
|
+
# Apple Touch Icon (iOS 主屏幕图标)
|
|
22
|
+
- ./web/apple-touch-icon.png:/app/apps/web/public/apple-touch-icon.png
|
|
@@ -91,11 +91,7 @@ services:
|
|
|
91
91
|
PORT: 3100
|
|
92
92
|
SERVER_API_URL: ${SERVER_API_URL:-http://api:7100}
|
|
93
93
|
TZ: ${TZ:-Asia/Shanghai}
|
|
94
|
-
#
|
|
95
|
-
# 自定义资源挂载(如有需要,请取消注释并在同级目录下创建 web 文件夹及对应文件)
|
|
96
|
-
# - ./web/favicon.ico:/app/apps/web/public/favicon.ico
|
|
97
|
-
# - ./web/logo.svg:/app/apps/web/public/logo.svg
|
|
98
|
-
# - ./web/apple-touch-icon.png:/app/apps/web/public/apple-touch-icon.png
|
|
94
|
+
# 自定义资源挂载请使用 config.yml,参见 src/templates/config.yml
|
|
99
95
|
ports:
|
|
100
96
|
- "${WEB_PORT:-3100}:3100"
|
|
101
97
|
depends_on:
|
package/dist/templates/env
CHANGED
package/dist/utils/docker.js
CHANGED
|
@@ -75,6 +75,12 @@ export async function getComposeFiles(env) {
|
|
|
75
75
|
files.push(templateBuild);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
// 4. 检测自定义配置文件 (config.yml)
|
|
79
|
+
// 用于挂载自定义静态资源(如 logo、favicon 等),仅当文件存在时加载
|
|
80
|
+
const configFile = path.join(workDir, 'config.yml');
|
|
81
|
+
if (await fileExists(configFile)) {
|
|
82
|
+
files.push(configFile);
|
|
83
|
+
}
|
|
78
84
|
return { files, isBuiltIn };
|
|
79
85
|
}
|
|
80
86
|
/**
|
package/oclif.manifest.json
CHANGED
|
@@ -1,5 +1,121 @@
|
|
|
1
1
|
{
|
|
2
2
|
"commands": {
|
|
3
|
+
"backup:all": {
|
|
4
|
+
"aliases": [],
|
|
5
|
+
"args": {},
|
|
6
|
+
"description": "一键备份全部数据 (数据库 + 上传文件)",
|
|
7
|
+
"flags": {
|
|
8
|
+
"output": {
|
|
9
|
+
"char": "o",
|
|
10
|
+
"description": "输出目录路径",
|
|
11
|
+
"name": "output",
|
|
12
|
+
"hasDynamicHelp": false,
|
|
13
|
+
"multiple": false,
|
|
14
|
+
"type": "option"
|
|
15
|
+
},
|
|
16
|
+
"env": {
|
|
17
|
+
"char": "e",
|
|
18
|
+
"description": "运行环境 (production, lowmem, default)",
|
|
19
|
+
"name": "env",
|
|
20
|
+
"hasDynamicHelp": false,
|
|
21
|
+
"multiple": false,
|
|
22
|
+
"options": [
|
|
23
|
+
"production",
|
|
24
|
+
"lowmem",
|
|
25
|
+
"default"
|
|
26
|
+
],
|
|
27
|
+
"type": "option"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"hasDynamicHelp": false,
|
|
31
|
+
"hiddenAliases": [],
|
|
32
|
+
"id": "backup:all",
|
|
33
|
+
"pluginAlias": "nodebbs",
|
|
34
|
+
"pluginName": "nodebbs",
|
|
35
|
+
"pluginType": "core",
|
|
36
|
+
"strict": true,
|
|
37
|
+
"enableJsonFlag": false,
|
|
38
|
+
"isESM": true,
|
|
39
|
+
"relativePath": [
|
|
40
|
+
"dist",
|
|
41
|
+
"commands",
|
|
42
|
+
"backup",
|
|
43
|
+
"all.js"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"backup:db": {
|
|
47
|
+
"aliases": [],
|
|
48
|
+
"args": {},
|
|
49
|
+
"description": "备份数据库 (PostgreSQL)",
|
|
50
|
+
"flags": {
|
|
51
|
+
"env": {
|
|
52
|
+
"char": "e",
|
|
53
|
+
"description": "部署环境 (production, lowmem, default)",
|
|
54
|
+
"name": "env",
|
|
55
|
+
"hasDynamicHelp": false,
|
|
56
|
+
"multiple": false,
|
|
57
|
+
"options": [
|
|
58
|
+
"production",
|
|
59
|
+
"lowmem",
|
|
60
|
+
"default"
|
|
61
|
+
],
|
|
62
|
+
"type": "option"
|
|
63
|
+
},
|
|
64
|
+
"output": {
|
|
65
|
+
"char": "o",
|
|
66
|
+
"description": "输出文件路径",
|
|
67
|
+
"name": "output",
|
|
68
|
+
"hasDynamicHelp": false,
|
|
69
|
+
"multiple": false,
|
|
70
|
+
"type": "option"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"hasDynamicHelp": false,
|
|
74
|
+
"hiddenAliases": [],
|
|
75
|
+
"id": "backup:db",
|
|
76
|
+
"pluginAlias": "nodebbs",
|
|
77
|
+
"pluginName": "nodebbs",
|
|
78
|
+
"pluginType": "core",
|
|
79
|
+
"strict": true,
|
|
80
|
+
"enableJsonFlag": false,
|
|
81
|
+
"isESM": true,
|
|
82
|
+
"relativePath": [
|
|
83
|
+
"dist",
|
|
84
|
+
"commands",
|
|
85
|
+
"backup",
|
|
86
|
+
"db.js"
|
|
87
|
+
]
|
|
88
|
+
},
|
|
89
|
+
"backup:uploads": {
|
|
90
|
+
"aliases": [],
|
|
91
|
+
"args": {},
|
|
92
|
+
"description": "备份上传文件 (用户头像、附件等)",
|
|
93
|
+
"flags": {
|
|
94
|
+
"output": {
|
|
95
|
+
"char": "o",
|
|
96
|
+
"description": "输出文件路径 (.tar.gz)",
|
|
97
|
+
"name": "output",
|
|
98
|
+
"hasDynamicHelp": false,
|
|
99
|
+
"multiple": false,
|
|
100
|
+
"type": "option"
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"hasDynamicHelp": false,
|
|
104
|
+
"hiddenAliases": [],
|
|
105
|
+
"id": "backup:uploads",
|
|
106
|
+
"pluginAlias": "nodebbs",
|
|
107
|
+
"pluginName": "nodebbs",
|
|
108
|
+
"pluginType": "core",
|
|
109
|
+
"strict": true,
|
|
110
|
+
"enableJsonFlag": false,
|
|
111
|
+
"isESM": true,
|
|
112
|
+
"relativePath": [
|
|
113
|
+
"dist",
|
|
114
|
+
"commands",
|
|
115
|
+
"backup",
|
|
116
|
+
"uploads.js"
|
|
117
|
+
]
|
|
118
|
+
},
|
|
3
119
|
"clean": {
|
|
4
120
|
"aliases": [],
|
|
5
121
|
"args": {},
|
|
@@ -54,10 +170,10 @@
|
|
|
54
170
|
"index.js"
|
|
55
171
|
]
|
|
56
172
|
},
|
|
57
|
-
"db:
|
|
173
|
+
"db:push": {
|
|
58
174
|
"aliases": [],
|
|
59
175
|
"args": {},
|
|
60
|
-
"description": "
|
|
176
|
+
"description": "推送数据库 schema (db:push)",
|
|
61
177
|
"flags": {
|
|
62
178
|
"env": {
|
|
63
179
|
"char": "e",
|
|
@@ -71,19 +187,11 @@
|
|
|
71
187
|
"default"
|
|
72
188
|
],
|
|
73
189
|
"type": "option"
|
|
74
|
-
},
|
|
75
|
-
"output": {
|
|
76
|
-
"char": "o",
|
|
77
|
-
"description": "输出文件路径",
|
|
78
|
-
"name": "output",
|
|
79
|
-
"hasDynamicHelp": false,
|
|
80
|
-
"multiple": false,
|
|
81
|
-
"type": "option"
|
|
82
190
|
}
|
|
83
191
|
},
|
|
84
192
|
"hasDynamicHelp": false,
|
|
85
193
|
"hiddenAliases": [],
|
|
86
|
-
"id": "db:
|
|
194
|
+
"id": "db:push",
|
|
87
195
|
"pluginAlias": "nodebbs",
|
|
88
196
|
"pluginName": "nodebbs",
|
|
89
197
|
"pluginType": "core",
|
|
@@ -94,13 +202,13 @@
|
|
|
94
202
|
"dist",
|
|
95
203
|
"commands",
|
|
96
204
|
"db",
|
|
97
|
-
"
|
|
205
|
+
"push.js"
|
|
98
206
|
]
|
|
99
207
|
},
|
|
100
|
-
"db:
|
|
208
|
+
"db:reset": {
|
|
101
209
|
"aliases": [],
|
|
102
210
|
"args": {},
|
|
103
|
-
"description": "
|
|
211
|
+
"description": "重置数据库 (db:reset) - 危险操作!",
|
|
104
212
|
"flags": {
|
|
105
213
|
"env": {
|
|
106
214
|
"char": "e",
|
|
@@ -114,19 +222,11 @@
|
|
|
114
222
|
"default"
|
|
115
223
|
],
|
|
116
224
|
"type": "option"
|
|
117
|
-
},
|
|
118
|
-
"input": {
|
|
119
|
-
"char": "i",
|
|
120
|
-
"description": "输入文件路径",
|
|
121
|
-
"name": "input",
|
|
122
|
-
"hasDynamicHelp": false,
|
|
123
|
-
"multiple": false,
|
|
124
|
-
"type": "option"
|
|
125
225
|
}
|
|
126
226
|
},
|
|
127
227
|
"hasDynamicHelp": false,
|
|
128
228
|
"hiddenAliases": [],
|
|
129
|
-
"id": "db:
|
|
229
|
+
"id": "db:reset",
|
|
130
230
|
"pluginAlias": "nodebbs",
|
|
131
231
|
"pluginName": "nodebbs",
|
|
132
232
|
"pluginType": "core",
|
|
@@ -137,13 +237,13 @@
|
|
|
137
237
|
"dist",
|
|
138
238
|
"commands",
|
|
139
239
|
"db",
|
|
140
|
-
"
|
|
240
|
+
"reset.js"
|
|
141
241
|
]
|
|
142
242
|
},
|
|
143
|
-
"db:
|
|
243
|
+
"db:seed": {
|
|
144
244
|
"aliases": [],
|
|
145
245
|
"args": {},
|
|
146
|
-
"description": "
|
|
246
|
+
"description": "填充种子数据 (db:seed)",
|
|
147
247
|
"flags": {
|
|
148
248
|
"env": {
|
|
149
249
|
"char": "e",
|
|
@@ -161,7 +261,7 @@
|
|
|
161
261
|
},
|
|
162
262
|
"hasDynamicHelp": false,
|
|
163
263
|
"hiddenAliases": [],
|
|
164
|
-
"id": "db:
|
|
264
|
+
"id": "db:seed",
|
|
165
265
|
"pluginAlias": "nodebbs",
|
|
166
266
|
"pluginName": "nodebbs",
|
|
167
267
|
"pluginType": "core",
|
|
@@ -172,17 +272,25 @@
|
|
|
172
272
|
"dist",
|
|
173
273
|
"commands",
|
|
174
274
|
"db",
|
|
175
|
-
"
|
|
275
|
+
"seed.js"
|
|
176
276
|
]
|
|
177
277
|
},
|
|
178
|
-
"
|
|
278
|
+
"import:all": {
|
|
179
279
|
"aliases": [],
|
|
180
280
|
"args": {},
|
|
181
|
-
"description": "
|
|
281
|
+
"description": "一键恢复全部数据 (数据库 + 上传文件)",
|
|
182
282
|
"flags": {
|
|
283
|
+
"dir": {
|
|
284
|
+
"char": "d",
|
|
285
|
+
"description": "备份目录路径 (包含 .sql 和 .tar.gz 文件)",
|
|
286
|
+
"name": "dir",
|
|
287
|
+
"hasDynamicHelp": false,
|
|
288
|
+
"multiple": false,
|
|
289
|
+
"type": "option"
|
|
290
|
+
},
|
|
183
291
|
"env": {
|
|
184
292
|
"char": "e",
|
|
185
|
-
"description": "
|
|
293
|
+
"description": "运行环境 (production, lowmem, default)",
|
|
186
294
|
"name": "env",
|
|
187
295
|
"hasDynamicHelp": false,
|
|
188
296
|
"multiple": false,
|
|
@@ -196,7 +304,7 @@
|
|
|
196
304
|
},
|
|
197
305
|
"hasDynamicHelp": false,
|
|
198
306
|
"hiddenAliases": [],
|
|
199
|
-
"id": "
|
|
307
|
+
"id": "import:all",
|
|
200
308
|
"pluginAlias": "nodebbs",
|
|
201
309
|
"pluginName": "nodebbs",
|
|
202
310
|
"pluginType": "core",
|
|
@@ -206,14 +314,14 @@
|
|
|
206
314
|
"relativePath": [
|
|
207
315
|
"dist",
|
|
208
316
|
"commands",
|
|
209
|
-
"
|
|
210
|
-
"
|
|
317
|
+
"import",
|
|
318
|
+
"all.js"
|
|
211
319
|
]
|
|
212
320
|
},
|
|
213
|
-
"db
|
|
321
|
+
"import:db": {
|
|
214
322
|
"aliases": [],
|
|
215
323
|
"args": {},
|
|
216
|
-
"description": "
|
|
324
|
+
"description": "导入数据库 (从 SQL 文件恢复)",
|
|
217
325
|
"flags": {
|
|
218
326
|
"env": {
|
|
219
327
|
"char": "e",
|
|
@@ -227,11 +335,19 @@
|
|
|
227
335
|
"default"
|
|
228
336
|
],
|
|
229
337
|
"type": "option"
|
|
338
|
+
},
|
|
339
|
+
"input": {
|
|
340
|
+
"char": "i",
|
|
341
|
+
"description": "输入文件路径",
|
|
342
|
+
"name": "input",
|
|
343
|
+
"hasDynamicHelp": false,
|
|
344
|
+
"multiple": false,
|
|
345
|
+
"type": "option"
|
|
230
346
|
}
|
|
231
347
|
},
|
|
232
348
|
"hasDynamicHelp": false,
|
|
233
349
|
"hiddenAliases": [],
|
|
234
|
-
"id": "db
|
|
350
|
+
"id": "import:db",
|
|
235
351
|
"pluginAlias": "nodebbs",
|
|
236
352
|
"pluginName": "nodebbs",
|
|
237
353
|
"pluginType": "core",
|
|
@@ -241,8 +357,38 @@
|
|
|
241
357
|
"relativePath": [
|
|
242
358
|
"dist",
|
|
243
359
|
"commands",
|
|
244
|
-
"
|
|
245
|
-
"
|
|
360
|
+
"import",
|
|
361
|
+
"db.js"
|
|
362
|
+
]
|
|
363
|
+
},
|
|
364
|
+
"import:uploads": {
|
|
365
|
+
"aliases": [],
|
|
366
|
+
"args": {},
|
|
367
|
+
"description": "恢复上传文件 (从 tar.gz 备份恢复)",
|
|
368
|
+
"flags": {
|
|
369
|
+
"input": {
|
|
370
|
+
"char": "i",
|
|
371
|
+
"description": "输入文件路径 (.tar.gz)",
|
|
372
|
+
"name": "input",
|
|
373
|
+
"hasDynamicHelp": false,
|
|
374
|
+
"multiple": false,
|
|
375
|
+
"type": "option"
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
"hasDynamicHelp": false,
|
|
379
|
+
"hiddenAliases": [],
|
|
380
|
+
"id": "import:uploads",
|
|
381
|
+
"pluginAlias": "nodebbs",
|
|
382
|
+
"pluginName": "nodebbs",
|
|
383
|
+
"pluginType": "core",
|
|
384
|
+
"strict": true,
|
|
385
|
+
"enableJsonFlag": false,
|
|
386
|
+
"isESM": true,
|
|
387
|
+
"relativePath": [
|
|
388
|
+
"dist",
|
|
389
|
+
"commands",
|
|
390
|
+
"import",
|
|
391
|
+
"uploads.js"
|
|
246
392
|
]
|
|
247
393
|
},
|
|
248
394
|
"logs:all": {
|
|
@@ -781,5 +927,5 @@
|
|
|
781
927
|
]
|
|
782
928
|
}
|
|
783
929
|
},
|
|
784
|
-
"version": "0.
|
|
930
|
+
"version": "0.4.1"
|
|
785
931
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodebbs",
|
|
3
3
|
"description": "NodeBBS 论坛系统专业运维工具",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"author": "wengqianshan",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nodebbs": "./bin/run.js"
|
|
@@ -62,7 +62,13 @@
|
|
|
62
62
|
"description": "查看服务日志"
|
|
63
63
|
},
|
|
64
64
|
"db": {
|
|
65
|
-
"description": "数据库操作 (
|
|
65
|
+
"description": "数据库操作 (种子数据, 重置等)"
|
|
66
|
+
},
|
|
67
|
+
"backup": {
|
|
68
|
+
"description": "备份数据 (数据库, 上传文件等)"
|
|
69
|
+
},
|
|
70
|
+
"import": {
|
|
71
|
+
"description": "导入/恢复数据"
|
|
66
72
|
},
|
|
67
73
|
"shell": {
|
|
68
74
|
"description": "进入容器终端"
|