nodebbs 0.3.2 → 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 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
- | **shell** | 进入容器终端进行调试(支持选择特定服务)|
99
- | **db** | 数据库高级操作:<br>• 备份 (backup)<br>• 恢复 (import)<br>• 迁移 (migrate)<br>• 种子数据 (seed) |
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
 
@@ -131,7 +134,7 @@ vi .env
131
134
 
132
135
  - **production** - 生产环境(推荐,启用资源限制与安全配置)
133
136
  - **lowmem** - 低配环境(适用于 1C2G 服务器)
134
- - **basic** - 基础环境(测试用,无资源限制)
137
+ - **default** - 默认环境(无资源限制,适用于开发或高配服务器)
135
138
 
136
139
  ### 环境变量
137
140
 
@@ -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 DbBackup extends Command {
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 DbBackup extends Command {
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(DbBackup);
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 = `backup_${timestamp}.sql`;
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 DbImport extends Command {
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 DbImport extends Command {
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(DbImport);
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
- // 加载环境配置获取数据库信息 (用于可能的连接参数,虽然 docker exec 默认用 postgres 用户通常足够)
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 (Drop & Create public)
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
+ }
@@ -2,6 +2,7 @@ import { Command } from '@oclif/core';
2
2
  import { runCompose, getComposeFiles } from '../../utils/docker.js';
3
3
  import { logger } from '../../utils/logger.js';
4
4
  import { EnvFlag, selectEnvironment } from '../../utils/selection.js';
5
+ import { getEnvValue } from '../../utils/env.js';
5
6
  export default class ShellDb extends Command {
6
7
  static description = '进入数据库 shell (psql)';
7
8
  static flags = {
@@ -11,7 +12,9 @@ export default class ShellDb extends Command {
11
12
  const { flags } = await this.parse(ShellDb);
12
13
  const env = await selectEnvironment(flags.env);
13
14
  const { files, isBuiltIn } = await getComposeFiles(env);
15
+ // 从环境变量获取数据库名,默认为 nodebbs
16
+ const dbName = await getEnvValue('POSTGRES_DB') || 'nodebbs';
14
17
  logger.info('正在进入数据库 shell...');
15
- await runCompose(files, ['exec', 'postgres', 'psql', '-U', 'postgres', '-d', 'nodebbs'], isBuiltIn);
18
+ await runCompose(files, ['exec', 'postgres', 'psql', '-U', 'postgres', '-d', dbName], isBuiltIn);
16
19
  }
17
20
  }