nodebbs 0.0.3 → 0.0.5

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
@@ -1,6 +1,6 @@
1
1
  # NodeBBS CLI
2
2
 
3
- > 全栈友好的 NodeBBS 部署和管理命令行工具
3
+ > NodeBBS 论坛系统专业运维工具
4
4
 
5
5
  [![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io)
6
6
  [![Version](https://img.shields.io/npm/v/nodebbs.svg)](https://npmjs.org/package/nodebbs)
@@ -11,7 +11,8 @@ NodeBBS CLI 是一个专为全栈开发者设计的命令行工具,用于简
11
11
 
12
12
  ### 特性
13
13
 
14
- - 🚀 **快速启动** - 一键启动开发环境
14
+ - �️ **交互式菜单** - 支持键盘导航的可视化命令选择(新增)
15
+ - �🚀 **快速启动** - 一键启动开发环境
15
16
  - 🎯 **全栈友好** - 命令设计贴近开发者思维
16
17
  - 🔧 **服务级控制** - 可以单独管理每个服务
17
18
  - 📊 **实时日志** - 方便查看各服务日志
@@ -32,9 +33,13 @@ pnpm add -g nodebbs
32
33
  yarn global add nodebbs
33
34
  ```
34
35
 
35
- 或者直接使用 npx(无需安装):
36
+ 或者直接使用 npx(推荐):
36
37
 
37
38
  ```bash
39
+ # 进入交互式菜单(推荐)
40
+ npx nodebbs
41
+
42
+ # 运行特定命令
38
43
  npx nodebbs [command]
39
44
  ```
40
45
 
@@ -66,10 +71,10 @@ npx nodebbs status
66
71
  ## 📚 命令参考
67
72
 
68
73
  #### `nodebbs start`
69
- 启动 NodeBBS 服务
74
+ 开始部署
70
75
 
71
76
  ```bash
72
- # 交互式启动(推荐初次使用)
77
+ # 交互式启动
73
78
  npx nodebbs start
74
79
 
75
80
  # 指定环境启动
@@ -105,7 +110,7 @@ npx nodebbs restart -e production
105
110
  ```bash
106
111
  npx nodebbs stop
107
112
  npx nodebbs stop -e production
108
- ```
113
+
109
114
  # 停止服务并删除数据卷(危险!)
110
115
  npx nodebbs stop --volumes
111
116
  npx nodebbs stop -v
@@ -198,12 +203,7 @@ npx nodebbs shell:redis
198
203
 
199
204
  ### 数据库管理
200
205
 
201
- #### `nodebbs db`
202
- 打开数据库管理界面(Drizzle Studio)
203
206
 
204
- ```bash
205
- npx nodebbs db
206
- ```
207
207
 
208
208
  #### `nodebbs db:generate`
209
209
  生成数据库迁移文件
@@ -290,8 +290,7 @@ npx nodebbs logs:api
290
290
  # 进入 API 容器调试
291
291
  npx nodebbs shell:api
292
292
 
293
- # 打开数据库管理
294
- npx nodebbs db
293
+
295
294
 
296
295
  # 重置测试数据
297
296
  npx nodebbs db:reset
@@ -329,8 +328,7 @@ npx nodebbs db:migrate
329
328
  ### 场景 5:数据库操作
330
329
 
331
330
  ```bash
332
- # 打开数据库管理界面
333
- npx nodebbs db
331
+
334
332
 
335
333
  # 运行迁移
336
334
  npx nodebbs db:migrate
@@ -342,23 +340,7 @@ npx nodebbs db:seed
342
340
  npx nodebbs shell:db
343
341
  ```
344
342
 
345
- ## 🔄 从 Makefile 迁移
346
-
347
- 如果你之前使用 Makefile,这里是命令对照表:
348
343
 
349
- | Makefile 命令 | NodeBBS CLI 命令 |
350
- |--------------|-----------------|
351
- | `make up` | `npx nodebbs start` |
352
- | `make build` | `npx nodebbs start --build` |
353
- | `make rebuild` | `npx nodebbs start --build` |
354
- | `ENV=prod make rebuild` | `npx nodebbs start -e production --build` |
355
- | `make down` | `npx nodebbs stop` |
356
- | `make ps` | `npx nodebbs status` |
357
- | `make logs` | `npx nodebbs logs` |
358
- | `make logs-api` | `npx nodebbs logs:api` |
359
- | `make exec-api` | `npx nodebbs shell:api` |
360
- | `make db-studio` | `npx nodebbs db` |
361
- | `make clean-all` | `npx nodebbs stop --volumes` |
362
344
 
363
345
  ## ⚙️ 环境配置
364
346
 
@@ -393,6 +375,16 @@ CORS_ORIGIN=*
393
375
 
394
376
  > **提示**:可以使用 `openssl rand -hex 32` 命令生成安全的随机密钥。
395
377
 
378
+ ### 环境持久化
379
+
380
+ CLI 会自动记住您上次启动的环境:
381
+
382
+ 1. 当您运行 `nodebbs start` 并选择环境(如 `production`)后,CLI 会在当前目录创建 `.nodebbs-env` 文件记录该选择。
383
+ 2. 后续运行 `nodebbs logs`, `nodebbs status` 等命令时,会自动使用该环境,无需再次指定 `-e production`。
384
+ 3. 运行 `nodebbs stop` 成功停止服务后,会自动删除 `.nodebbs-env` 文件。
385
+
386
+ **注意**:如果您需要临时操作其他环境,仍然可以使用 `-e` 参数强制指定,例如 `nodebbs logs -e basic`。
387
+
396
388
  ## 🛠️ 高级用法
397
389
 
398
390
  ### 内置模板
package/bin/dev.js CHANGED
@@ -2,4 +2,9 @@
2
2
 
3
3
  import {execute} from '@oclif/core'
4
4
 
5
- await execute({development: true, dir: import.meta.url})
5
+ if (process.argv.length <= 2) {
6
+ const {runInteractive} = await import('../src/interactive.ts')
7
+ await runInteractive(import.meta.url)
8
+ } else {
9
+ await execute({development: true, dir: import.meta.url})
10
+ }
package/bin/run.js CHANGED
@@ -2,4 +2,9 @@
2
2
 
3
3
  import {execute} from '@oclif/core'
4
4
 
5
- await execute({dir: import.meta.url})
5
+ if (process.argv.length <= 2) {
6
+ const {runInteractive} = await import('../dist/interactive.js')
7
+ await runInteractive(import.meta.url)
8
+ } else {
9
+ await execute({dir: import.meta.url})
10
+ }
@@ -27,7 +27,7 @@ export default class Clean extends Command {
27
27
  async run() {
28
28
  const { flags } = await this.parse(Clean);
29
29
  let targets = [];
30
- // Determine targets from flags
30
+ // 根据标志确定清理目标
31
31
  if (flags.all) {
32
32
  targets = ['cache', 'images', 'networks'];
33
33
  }
@@ -37,7 +37,7 @@ export default class Clean extends Command {
37
37
  if (flags.images)
38
38
  targets.push('images');
39
39
  }
40
- // Interactive selection if no flags
40
+ // 如果未提供标志,进行交互式选择
41
41
  if (targets.length === 0) {
42
42
  targets = await checkbox({
43
43
  message: '请选择要清理的项目:',
@@ -52,7 +52,7 @@ export default class Clean extends Command {
52
52
  logger.info('未选择任何清理项目。');
53
53
  return;
54
54
  }
55
- // Confirmation
55
+ // 确认提示
56
56
  if (!flags.force) {
57
57
  logger.warning(`即将清理: ${targets.join(', ')}`);
58
58
  const confirmed = await confirm({
@@ -64,7 +64,7 @@ export default class Clean extends Command {
64
64
  return;
65
65
  }
66
66
  }
67
- // Execute
67
+ // 执行清理
68
68
  try {
69
69
  if (targets.includes('cache')) {
70
70
  logger.info('正在清理构建缓存...');
@@ -17,33 +17,33 @@ export default class DbBackup extends Command {
17
17
  };
18
18
  async run() {
19
19
  const { flags } = await this.parse(DbBackup);
20
- // 1. Select Environment
20
+ // 1. 选择环境
21
21
  const env = await selectEnvironment(flags.env);
22
- // 2. Determine Output File
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
26
  outputFile = `backup_${timestamp}.sql`;
27
27
  }
28
- // Ensure absolute path
28
+ // 确保绝对路径
29
29
  const outputPath = path.resolve(process.cwd(), outputFile);
30
- // 3. Get Compose Files
30
+ // 3. 获取 Compose 文件
31
31
  const { files, isBuiltIn } = await getComposeFiles(env);
32
32
  logger.info(`正在备份数据库到: ${outputPath}`);
33
33
  logger.info('环境: ' + env);
34
- // Load Env Config
34
+ // 加载环境配置
35
35
  const envConfig = dotenv.config().parsed || {};
36
36
  const dbUser = envConfig.POSTGRES_USER || 'postgres';
37
37
  const dbName = envConfig.POSTGRES_DB || 'nodebbs';
38
38
  const dbPassword = envConfig.POSTGRES_PASSWORD;
39
- // 4. Construct Compose Args
39
+ // 4. 构建 Compose 参数
40
40
  const composeArgs = files.flatMap(f => ['-f', f]);
41
41
  if (isBuiltIn) {
42
42
  composeArgs.push('--project-directory', process.cwd());
43
43
  }
44
- // 5. Run pg_dump
44
+ // 5. 运行 pg_dump
45
45
  const dumpArgs = [...composeArgs, 'exec', '-T'];
46
- // Inject password if available
46
+ // 如果有密码则注入
47
47
  if (dbPassword) {
48
48
  dumpArgs.push('-e', `PGPASSWORD=${dbPassword}`);
49
49
  }
@@ -54,7 +54,7 @@ export default class DbBackup extends Command {
54
54
  subprocess.stdout.pipe(fs.createWriteStream(outputPath));
55
55
  }
56
56
  await subprocess;
57
- // check file size
57
+ // 检查文件大小
58
58
  const stats = fs.statSync(outputPath);
59
59
  if (stats.size === 0) {
60
60
  logger.error('备份文件为空,备份可能失败。');
@@ -68,7 +68,7 @@ export default class DbBackup extends Command {
68
68
  if (error instanceof Error) {
69
69
  logger.error(error.message);
70
70
  }
71
- // Cleanup empty file if failed
71
+ // 如果失败则清理空文件
72
72
  if (fs.existsSync(outputPath) && fs.statSync(outputPath).size === 0) {
73
73
  fs.unlinkSync(outputPath);
74
74
  }
@@ -0,0 +1,8 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Pack extends Command {
3
+ static description: string;
4
+ static flags: {
5
+ output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
6
+ };
7
+ run(): Promise<void>;
8
+ }
@@ -0,0 +1,150 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { execa } from 'execa';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+ import { getTemplatePath } from '../../utils/template.js';
7
+ export default class Pack extends Command {
8
+ static description = '生成离线部署包';
9
+ static flags = {
10
+ output: Flags.string({ char: 'o', description: '输出文件名', default: 'nodebbs-offline.tar.gz' }),
11
+ };
12
+ async run() {
13
+ const { flags } = await this.parse(Pack);
14
+ const outputPath = path.resolve(flags.output);
15
+ // 检查 docker 是否可用
16
+ try {
17
+ await execa('docker', ['--version']);
18
+ }
19
+ catch {
20
+ this.error('无法执行 docker 命令,请确保 Docker 已安装并运行。');
21
+ }
22
+ // 1. 准备临时目录
23
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodebbs-pack-'));
24
+ this.log(`正在准备构建环境: ${tmpDir}`);
25
+ try {
26
+ // 2. 导出配置文件
27
+ // 这里我们需要获取当前的 docker-compose.yml 和 prod 配置
28
+ // 假设用户当前目录下有这些文件,或者我们就用内置模板?
29
+ // 更好的方式是:如果在当前目录找到了配置文件,就用当前的;否则用内置模板。
30
+ // 但既然是打包,通常是想把当前开发好的代码打包。
31
+ // 所以我们假设在项目根目录运行。
32
+ if (!fs.existsSync('docker-compose.yml')) {
33
+ this.warn('当前目录未找到 docker-compose.yml,将使用内置模板。');
34
+ // TODO: 从 templates 复制 (简化起见,我们假设用户在项目根目录,或者我们强制要求)
35
+ // 如果没找到,我们实际上无法 build api/web,因为 build context 需要源码。
36
+ // 所以必须要求在项目根目录运行。
37
+ this.error('请在 NodeBBS 项目根目录下运行此命令 (需要包含 docker-compose.yml 和源代码)。');
38
+ }
39
+ // 3. 构建镜像
40
+ this.log('正在构建应用镜像 (这可能通过需要几分钟)...');
41
+ await execa('docker', ['compose', 'build'], { stdio: 'inherit' });
42
+ // 4. 拉取依赖镜像
43
+ this.log('正在确保数据库镜像已下载...');
44
+ await execa('docker', ['pull', 'postgres:16-alpine'], { stdio: 'inherit' });
45
+ await execa('docker', ['pull', 'redis:7-alpine'], { stdio: 'inherit' });
46
+ // 5. 导出镜像
47
+ this.log('正在导出镜像到文件 (nodebbs-images.tar)...');
48
+ // 获取实际的镜像名,这里假设 docker-compose build 生成的镜像名符合预期
49
+ // 通常是 <dir>_api 和 <dir>_web,或者我们在 compose 文件里指定了 image name?
50
+ // 查看 template: 没有指定 image name,所以默认为 ${dirname}-api。
51
+ // 为了确保准确,我们可以解析 docker compose config 的输出,或者强制指定 image name。
52
+ // 更稳妥的方式:直接 save 指定的服务镜像。
53
+ // docker compose images -q 可以获取 ID。
54
+ // 获取所有相关镜像的列表
55
+ const projectName = path.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9]/g, '');
56
+ const images = [
57
+ 'postgres:16-alpine',
58
+ 'redis:7-alpine',
59
+ `${projectName}-api`, // 默认命名规则
60
+ `${projectName}-web`
61
+ ];
62
+ // 我们最好通过 docker compose config 来确认镜像名,但这比较复杂。
63
+ // 简单策略:先 save postgres 和 redis,对于 api 和 web,我们先 tag 一下以确保名字固定。
64
+ await execa('docker', ['tag', `${projectName}-api`, 'nodebbs-api:latest']);
65
+ await execa('docker', ['tag', `${projectName}-web`, 'nodebbs-web:latest']);
66
+ const imagesToSave = ['postgres:16-alpine', 'redis:7-alpine', 'nodebbs-api:latest', 'nodebbs-web:latest'];
67
+ await execa('docker', ['save', '-o', path.join(tmpDir, 'nodebbs-images.tar'), ...imagesToSave], { stdio: 'inherit' });
68
+ // 6. 复制配置文件 (并进行修改)
69
+ // 读取 docker-compose.yml 并移除开发环境的源码挂载
70
+ // 这是一个关键步骤,因为在离线/生产环境中,我们不应该挂载 ./apps/xxx/src,
71
+ // 否则会覆盖掉镜像内构建好的代码,导致 "Cannot find module" 错误。
72
+ let composeContent = fs.readFileSync('docker-compose.yml', 'utf-8');
73
+ const lines = composeContent.split('\n');
74
+ const cleanedLines = lines.map(line => {
75
+ // 移除 api 和 web 的源码挂载
76
+ if (line.includes('./apps/api/src') || line.includes('./apps/web/src')) {
77
+ return line.replace(/^/, '# [OFFLINE-PACK-REMOVED] ');
78
+ }
79
+ return line;
80
+ });
81
+ fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), cleanedLines.join('\n'));
82
+ if (fs.existsSync('docker-compose.prod.yml')) {
83
+ fs.copyFileSync('docker-compose.prod.yml', path.join(tmpDir, 'docker-compose.prod.yml'));
84
+ }
85
+ if (fs.existsSync('.env')) {
86
+ this.warn('检测到 .env 文件,出于安全考虑,不会默认打包 .env 文件。请在部署时手动配置环境变量。');
87
+ }
88
+ // 创建 .env.example
89
+ // 创建 .env.example
90
+ const envTemplatePath = getTemplatePath('env');
91
+ let envExample = '';
92
+ try {
93
+ envExample = fs.readFileSync(envTemplatePath, 'utf-8');
94
+ }
95
+ catch (e) {
96
+ this.warn(`读取 env 模板失败: ${envTemplatePath},将使用空模板`);
97
+ }
98
+ fs.writeFileSync(path.join(tmpDir, '.env.example'), envExample);
99
+ // 7. 创建安装脚本
100
+ const installScript = `#!/bin/bash
101
+ echo "正在加载 Docker 镜像..."
102
+ docker load -i nodebbs-images.tar
103
+
104
+ echo "正在启动服务..."
105
+ if [ ! -f .env ]; then
106
+ echo "警告: 未找到 .env 文件,将使用默认配置或报错。请先复制 .env.example 为 .env 并修改配置。"
107
+ fi
108
+
109
+ # 修改 image 名称以匹配 load 进来的 tag (如果 compose 文件里没写 image,它会尝试 build)
110
+ # 为了避免 build,我们需要修改 docker-compose.yml 或者使用环境变量 override
111
+ # 最简单的办法:我们在 install 脚本里,强制用 docker run 或者修改 compose 文件?
112
+ # 这里的难点是 compose file 里写的是 build: .
113
+ # 离线环境没法 build。
114
+ # 解决方案:生成一个专门的 docker-compose.offline.yml
115
+
116
+ cat > docker-compose.override.yml <<EOF
117
+ services:
118
+ api:
119
+ build: !reset
120
+ image: nodebbs-api:latest
121
+ web:
122
+ build: !reset
123
+ image: nodebbs-web:latest
124
+ EOF
125
+
126
+ docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.override.yml up -d
127
+
128
+ echo "部署完成!"
129
+ `;
130
+ fs.writeFileSync(path.join(tmpDir, 'install.sh'), installScript);
131
+ fs.chmodSync(path.join(tmpDir, 'install.sh'), '755');
132
+ // 8. 打包最终的 tar.gz
133
+ this.log(`正在生成最终压缩包: ${outputPath}`);
134
+ await execa('tar', ['-czf', outputPath, '-C', tmpDir, '.']);
135
+ this.log(`\n🎉 离线包生成成功: ${outputPath}`);
136
+ this.log('使用方法:');
137
+ this.log('1. 将压缩包上传到服务器');
138
+ this.log('2. 解压: tar -xzf nodebbs-offline.tar.gz');
139
+ this.log('3. 配置 .env');
140
+ this.log('4. 运行: ./install.sh');
141
+ }
142
+ catch (error) {
143
+ this.error(`打包失败: ${error.message}`);
144
+ }
145
+ finally {
146
+ // 清理临时目录
147
+ fs.rmSync(tmpDir, { recursive: true, force: true });
148
+ }
149
+ }
150
+ }
@@ -0,0 +1,6 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Rebuild extends Command {
3
+ static description: string;
4
+ static strict: boolean;
5
+ run(): Promise<void>;
6
+ }
@@ -0,0 +1,11 @@
1
+ import { Command } from '@oclif/core';
2
+ import Start from '../start/index.js';
3
+ export default class Rebuild extends Command {
4
+ static description = '重新构建并启动服务 (start --build)';
5
+ // Allow passing flags like -e to the underlying start command
6
+ static strict = false;
7
+ async run() {
8
+ // Invoke Start command with --build flag and any other arguments
9
+ await Start.run(['--build', ...this.argv]);
10
+ }
11
+ }
@@ -9,7 +9,7 @@ export default class Restart extends Command {
9
9
  };
10
10
  async run() {
11
11
  const { flags } = await this.parse(Restart);
12
- // 1. Select Environment
12
+ // 1. 选择环境
13
13
  const env = await selectEnvironment(flags.env);
14
14
  const { files, isBuiltIn } = await getComposeFiles(env);
15
15
  logger.info('正在重启服务...');
@@ -4,9 +4,9 @@ import { checkDocker, runCompose, waitForHealth, execCompose, getComposeFiles }
4
4
  import { initEnv, checkEnv } from '../../utils/env.js';
5
5
  import { logger } from '../../utils/logger.js';
6
6
  import dotenv from 'dotenv';
7
- import { EnvFlag, selectEnvironment } from '../../utils/selection.js';
7
+ import { EnvFlag, selectEnvironment, setStoredEnv } from '../../utils/selection.js';
8
8
  export default class Start extends Command {
9
- static description = '启动 NodeBBS (部署模式)';
9
+ static description = '开始部署';
10
10
  static flags = {
11
11
  env: EnvFlag,
12
12
  build: Flags.boolean({
@@ -23,9 +23,10 @@ export default class Start extends Command {
23
23
  else {
24
24
  logger.header('NodeBBS Docker 部署');
25
25
  }
26
- // 1. Select Environment
26
+ // 1. 选择环境
27
27
  const env = await selectEnvironment(flags.env);
28
- // 2. Get Compose Files
28
+ await setStoredEnv(env);
29
+ // 2. 获取 Compose 文件
29
30
  const { files: composeFiles, isBuiltIn } = await getComposeFiles(env);
30
31
  if (isBuiltIn) {
31
32
  logger.info('使用内置 Docker Compose 模板...');
@@ -45,9 +46,9 @@ export default class Start extends Command {
45
46
  logger.warning('注意:无资源限制,不推荐用于生产环境。');
46
47
  }
47
48
  }
48
- // 3. Check Docker & Env
49
+ // 3. 检查 Docker 和环境变量
49
50
  await checkDocker();
50
- // initEnv guarantees .env exists (or exits)
51
+ // initEnv 保证 .env 存在(或退出)
51
52
  await initEnv();
52
53
  await checkEnv(env);
53
54
  if (!flags.build) {
@@ -60,7 +61,7 @@ export default class Start extends Command {
60
61
  this.exit(0);
61
62
  }
62
63
  }
63
- // 4. Build & Start Services
64
+ // 4. 构建并启动服务
64
65
  if (flags.build) {
65
66
  logger.info('正在重新构建并启动服务...');
66
67
  await runCompose(composeFiles, ['up', '-d', '--build'], isBuiltIn);
@@ -74,7 +75,7 @@ export default class Start extends Command {
74
75
  await runCompose(composeFiles, ['up', '-d'], isBuiltIn);
75
76
  logger.success('服务启动指令已发送');
76
77
  }
77
- // 5. Post-Start Actions (Full Deploy only)
78
+ // 5. 启动后操作 (仅完整部署)
78
79
  if (!flags.build) {
79
80
  await waitForHealth(composeFiles, isBuiltIn);
80
81
  const pushDb = await confirm({
@@ -97,7 +98,7 @@ export default class Start extends Command {
97
98
  }
98
99
  }
99
100
  logger.header(flags.build ? '启动成功!' : 'NodeBBS 启动成功!');
100
- // 6. Show Info
101
+ // 6. 显示信息
101
102
  logger.info(`环境: ${env}`);
102
103
  const envConfig = dotenv.config().parsed || {};
103
104
  const webPort = envConfig.WEB_PORT || '3100';
@@ -2,7 +2,7 @@ import { Command, Flags } from '@oclif/core';
2
2
  import { runCompose, getComposeFiles } from '../../utils/docker.js';
3
3
  import { logger } from '../../utils/logger.js';
4
4
  import { confirm } from '@inquirer/prompts';
5
- import { EnvFlag, selectEnvironment } from '../../utils/selection.js';
5
+ import { EnvFlag, selectEnvironment, clearStoredEnv } from '../../utils/selection.js';
6
6
  export default class Stop extends Command {
7
7
  static description = '停止服务';
8
8
  static flags = {
@@ -15,7 +15,7 @@ export default class Stop extends Command {
15
15
  };
16
16
  async run() {
17
17
  const { flags } = await this.parse(Stop);
18
- // 1. Select Environment
18
+ // 1. 选择环境
19
19
  const env = await selectEnvironment(flags.env, {
20
20
  prompt: '请选择运行环境(停止服务需匹配启动环境):'
21
21
  });
@@ -32,11 +32,13 @@ export default class Stop extends Command {
32
32
  }
33
33
  logger.info('正在停止服务并删除数据卷...');
34
34
  await runCompose(files, ['down', '-v'], isBuiltIn);
35
+ await clearStoredEnv();
35
36
  logger.success('服务已停止,数据卷已删除');
36
37
  }
37
38
  else {
38
39
  logger.info('正在停止服务...');
39
40
  await runCompose(files, ['down'], isBuiltIn);
41
+ await clearStoredEnv();
40
42
  logger.success('服务已停止');
41
43
  }
42
44
  }
@@ -0,0 +1 @@
1
+ export declare function runInteractive(root: string): Promise<void>;
@@ -0,0 +1,171 @@
1
+ import { Config } from '@oclif/core';
2
+ import { select, Separator } from '@inquirer/prompts';
3
+ import { fileURLToPath } from 'url';
4
+ export async function runInteractive(root) {
5
+ // Config.load 需要文件路径而不是 URL
6
+ // 如果 root 是 import.meta.url,则进行转换
7
+ let rootPath = root.startsWith('file://') ? fileURLToPath(root) : root;
8
+ const config = await Config.load(rootPath);
9
+ // 构建命令树
10
+ const tree = { name: 'root', children: {} };
11
+ for (const cmd of config.commands) {
12
+ // 跳过隐藏命令
13
+ if (cmd.hidden)
14
+ continue;
15
+ const parts = cmd.id.split(':');
16
+ let currentNode = tree;
17
+ for (let i = 0; i < parts.length; i++) {
18
+ const part = parts[i];
19
+ if (!currentNode.children[part]) {
20
+ currentNode.children[part] = {
21
+ name: part,
22
+ children: {}
23
+ };
24
+ }
25
+ currentNode = currentNode.children[part];
26
+ }
27
+ // 将命令分配给叶节点
28
+ currentNode.command = cmd;
29
+ }
30
+ await navigate(tree, [], config);
31
+ }
32
+ async function navigate(node, breadcrumbs, config) {
33
+ const priorityOrder = ['start', 'rebuild', 'stop', 'restart', 'status', 'logs', 'pack', 'shell', 'db'];
34
+ while (true) {
35
+ const keys = Object.keys(node.children).sort((a, b) => {
36
+ const indexA = priorityOrder.indexOf(a);
37
+ const indexB = priorityOrder.indexOf(b);
38
+ // If both are in priority list, sort by index
39
+ if (indexA !== -1 && indexB !== -1)
40
+ return indexA - indexB;
41
+ // If only A is in priority list, A comes first
42
+ if (indexA !== -1)
43
+ return -1;
44
+ // If only B is in priority list, B comes first
45
+ if (indexB !== -1)
46
+ return 1;
47
+ // Otherwise sort alphabetically
48
+ return a.localeCompare(b);
49
+ });
50
+ const choices = keys.map(key => {
51
+ const child = node.children[key];
52
+ const hasSubcommands = Object.keys(child.children).length > 0;
53
+ let label = key;
54
+ let description = '';
55
+ // 尝试获取命令描述
56
+ if (key === 'help') {
57
+ description = '显示帮助信息';
58
+ }
59
+ else if (child.command?.description) {
60
+ description = child.command.description;
61
+ }
62
+ // 或者获取主题描述
63
+ else if (hasSubcommands) {
64
+ const fullId = [...breadcrumbs, key].join(':');
65
+ const topic = config.topics.find(t => t.name === fullId);
66
+ if (topic) {
67
+ description = topic.description || '';
68
+ }
69
+ }
70
+ if (description) {
71
+ const shortDesc = description.split('.')[0].substring(0, 50);
72
+ label = `${key.padEnd(12)} ${shortDesc}`;
73
+ }
74
+ else {
75
+ label = key.padEnd(12);
76
+ }
77
+ if (hasSubcommands) {
78
+ label = `${label} [+]`;
79
+ }
80
+ return {
81
+ name: label,
82
+ value: key,
83
+ short: key
84
+ };
85
+ });
86
+ // 添加导航选项
87
+ // 即使支持 Esc,保留显式选项也有助于发现性
88
+ if (breadcrumbs.length > 0) {
89
+ choices.push(new Separator());
90
+ choices.push({ name: '⬅️ 返回', value: '__BACK__', short: '返回' });
91
+ }
92
+ choices.push({ name: '❌ 退出', value: '__EXIT__', short: '退出' });
93
+ const controller = new AbortController();
94
+ // 监听 Esc 键
95
+ const onKeypress = (_str, key) => {
96
+ if (key && key.name === 'escape') {
97
+ controller.abort();
98
+ }
99
+ };
100
+ // 确保 keypress 事件被触发(inquirer 内部也会这样做,但为了安全起见)
101
+ // 注意:process.stdin 可能已经是 raw mode,inquirer 会处理它
102
+ // 我们只需要监听
103
+ process.stdin.on('keypress', onKeypress);
104
+ try {
105
+ const selection = await select({
106
+ message: breadcrumbs.length ? `选择命令 (${breadcrumbs.join(' > ')}):` : '选择命令:',
107
+ choices,
108
+ pageSize: 15,
109
+ loop: true, // 允许循环导航
110
+ // @ts-ignore: prompt signal support might be strict on types
111
+ signal: controller.signal
112
+ });
113
+ if (selection === '__EXIT__') {
114
+ process.exit(0);
115
+ }
116
+ if (selection === '__BACK__') {
117
+ return;
118
+ }
119
+ const selectedNode = node.children[selection];
120
+ if (Object.keys(selectedNode.children).length > 0) {
121
+ // 有子命令,进入子菜单
122
+ await navigate(selectedNode, [...breadcrumbs, selection], config);
123
+ }
124
+ else if (selectedNode.command) {
125
+ // 可运行的命令
126
+ console.log(`正在运行: ${selectedNode.command.id}`);
127
+ try {
128
+ await config.runCommand(selectedNode.command.id);
129
+ }
130
+ catch (error) {
131
+ // Ctrl+C: 退出程序
132
+ if (error.name === 'ExitPromptError') {
133
+ console.log('\n用户退出。');
134
+ process.exit(0);
135
+ }
136
+ // 手动取消: 返回菜单
137
+ if (error.name === 'CancelError') {
138
+ console.log('\n操作已取消。');
139
+ continue;
140
+ }
141
+ console.error(error);
142
+ }
143
+ console.log('\n命令执行完成。');
144
+ // 动态导入 input 以保持轻量
145
+ const { input } = await import('@inquirer/prompts');
146
+ await input({ message: '按回车键继续...' });
147
+ }
148
+ }
149
+ catch (error) {
150
+ // 处理 AbortError (Esc)
151
+ if (error.name === 'AbortError' || error.message?.includes('aborted')) {
152
+ if (breadcrumbs.length > 0) {
153
+ // 子菜单 -> 返回
154
+ return;
155
+ }
156
+ else {
157
+ // 顶级菜单 -> 退出
158
+ process.exit(0);
159
+ }
160
+ }
161
+ // 处理 Ctrl+C (ExitPromptError)
162
+ if (error.name === 'ExitPromptError') {
163
+ process.exit(0);
164
+ }
165
+ throw error;
166
+ }
167
+ finally {
168
+ process.stdin.off('keypress', onKeypress);
169
+ }
170
+ }
171
+ }
@@ -8,11 +8,11 @@ const fileExists = promisify(exists);
8
8
  export async function getComposeFiles(env) {
9
9
  const workDir = process.cwd();
10
10
  let isBuiltIn = false;
11
- // Check current directory for docker-compose.yml
11
+ // 检查当前目录是否存在 docker-compose.yml
12
12
  let baseFile = path.join(workDir, 'docker-compose.yml');
13
13
  let templateDir = workDir;
14
14
  if (!await fileExists(baseFile)) {
15
- // Use built-in templates
15
+ // 使用内置模板
16
16
  isBuiltIn = true;
17
17
  templateDir = getTemplateDir();
18
18
  baseFile = getTemplatePath('docker-compose.yml');
@@ -42,12 +42,12 @@ export async function runCompose(files, args, isBuiltIn = false) {
42
42
  const composeArgs = files.flatMap(f => ['-f', f]);
43
43
  if (isBuiltIn) {
44
44
  composeArgs.push('--project-directory', process.cwd());
45
- // Set INIT_DB_PATH to the built-in sql file
45
+ // INIT_DB_PATH 设置为内置 sql 文件
46
46
  const templateDir = path.dirname(files[0]);
47
47
  process.env.INIT_DB_PATH = path.join(templateDir, 'init-db.sql');
48
48
  }
49
49
  composeArgs.push(...args);
50
- // Use stdio: 'inherit' to show output in real-time
50
+ // 使用 stdio: 'inherit' 实时显示输出
51
51
  await execa('docker', ['compose', ...composeArgs], { stdio: 'inherit' });
52
52
  }
53
53
  export async function execCompose(files, service, command, isBuiltIn = false) {
@@ -60,7 +60,7 @@ export async function execCompose(files, service, command, isBuiltIn = false) {
60
60
  }
61
61
  export async function waitForHealth(files, isBuiltIn = false) {
62
62
  logger.info('正在等待服务就绪...');
63
- // Wait for Postgres
63
+ // 等待 Postgres
64
64
  logger.info('等待 PostgreSQL...');
65
65
  const composeArgs = files.flatMap(f => ['-f', f]);
66
66
  if (isBuiltIn) {
@@ -82,11 +82,11 @@ export async function waitForHealth(files, isBuiltIn = false) {
82
82
  if (retries === 0) {
83
83
  logger.warning('PostgreSQL 可能尚未就绪');
84
84
  }
85
- // Wait for Redis
85
+ // 等待 Redis
86
86
  logger.info('等待 Redis...');
87
87
  await new Promise(resolve => setTimeout(resolve, 5000));
88
88
  logger.success('Redis 已就绪');
89
- // Wait for API
89
+ // 等待 API
90
90
  logger.info('等待 API 服务...');
91
91
  await new Promise(resolve => setTimeout(resolve, 10000));
92
92
  logger.success('API 服务已就绪');
package/dist/utils/env.js CHANGED
@@ -18,7 +18,7 @@ export async function initEnv() {
18
18
  sourceFile = '.env.docker.example';
19
19
  }
20
20
  else {
21
- // Use built-in template
21
+ // 使用内置模板
22
22
  sourceFile = getTemplatePath('env');
23
23
  isBuiltIn = true;
24
24
  }
@@ -49,7 +49,7 @@ export async function initEnv() {
49
49
  }
50
50
  export async function checkEnv(envType) {
51
51
  logger.info('正在检查环境配置...');
52
- // Load .env
52
+ // 加载 .env
53
53
  const envConfig = dotenv.config().parsed || {};
54
54
  let warnings = 0;
55
55
  let errors = 0;
@@ -1,5 +1,7 @@
1
1
  export type EnvType = 'production' | 'lowmem' | 'basic';
2
2
  export declare const EnvFlag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
3
+ export declare function setStoredEnv(env: EnvType): Promise<void>;
4
+ export declare function clearStoredEnv(): Promise<void>;
3
5
  export declare function selectEnvironment(env?: string, options?: {
4
6
  prompt?: string;
5
7
  }): Promise<EnvType>;
@@ -1,24 +1,68 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { select } from '@inquirer/prompts';
3
3
  import { logger } from './logger.js';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ const ENV_MARKER_FILE = '.nodebbs-env';
4
7
  export const EnvFlag = Flags.string({
5
8
  char: 'e',
6
9
  description: '部署环境 (production, lowmem, basic)',
7
10
  options: ['production', 'lowmem', 'basic'],
8
11
  });
12
+ async function getStoredEnv() {
13
+ try {
14
+ const content = await fs.readFile(path.resolve(process.cwd(), ENV_MARKER_FILE), 'utf-8');
15
+ const env = content.trim();
16
+ if (['production', 'lowmem', 'basic'].includes(env)) {
17
+ return env;
18
+ }
19
+ }
20
+ catch { }
21
+ return null;
22
+ }
23
+ export async function setStoredEnv(env) {
24
+ try {
25
+ await fs.writeFile(path.resolve(process.cwd(), ENV_MARKER_FILE), env, 'utf-8');
26
+ }
27
+ catch (error) {
28
+ logger.warning(`无法保存环境标记: ${error}`);
29
+ }
30
+ }
31
+ export async function clearStoredEnv() {
32
+ try {
33
+ const filepath = path.resolve(process.cwd(), ENV_MARKER_FILE);
34
+ await fs.rm(filepath, { force: true });
35
+ }
36
+ catch (error) {
37
+ logger.warning(`无法清除环境标记: ${error}`);
38
+ }
39
+ }
9
40
  export async function selectEnvironment(env, options = {}) {
10
41
  if (env) {
11
- // oclif options validation handles valid values, but type casting is needed
42
+ // oclif 选项验证会处理有效值,但仍需类型转换
12
43
  return env;
13
44
  }
45
+ // 检查已存储的环境配置
46
+ const storedEnv = await getStoredEnv();
47
+ if (storedEnv) {
48
+ logger.info(`检测到运行环境: ${storedEnv}`);
49
+ return storedEnv;
50
+ }
14
51
  const selected = await select({
15
52
  message: options.prompt || '请选择运行环境:',
16
53
  choices: [
17
54
  { name: '标准生产环境 (2C4G+) [推荐]', value: 'production' },
18
55
  { name: '低配环境 (1C1G/1C2G)', value: 'lowmem' },
19
56
  { name: '基础环境 (仅用于测试)', value: 'basic' },
57
+ { name: '❌ 取消', value: '__CANCEL__' },
20
58
  ],
59
+ loop: true,
21
60
  });
61
+ if (selected === '__CANCEL__') {
62
+ const error = new Error('用户取消操作');
63
+ error.name = 'CancelError';
64
+ throw error;
65
+ }
22
66
  logger.info(`已选择环境: ${selected}`);
23
67
  return selected;
24
68
  }
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Get the absolute path to the templates directory.
3
- * In production (dist/utils), templates are in dist/templates.
4
- * In development (src/utils), templates are in src/templates.
2
+ * 获取模板目录的绝对路径。
3
+ * 在生产环境 (dist/utils) 中,模板位于 dist/templates
4
+ * 在开发环境 (src/utils) 中,模板位于 src/templates
5
5
  */
6
6
  export declare function getTemplateDir(): string;
7
7
  /**
8
- * Get the absolute path to a specific template file.
9
- * @param fileName The name of the template file (e.g., 'env', 'docker-compose.yml')
8
+ * 获取特定模板文件的绝对路径。
9
+ * @param fileName 模板文件名称 (例如 'env', 'docker-compose.yml')
10
10
  */
11
11
  export declare function getTemplatePath(fileName: string): string;
@@ -2,16 +2,16 @@ import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
4
4
  /**
5
- * Get the absolute path to the templates directory.
6
- * In production (dist/utils), templates are in dist/templates.
7
- * In development (src/utils), templates are in src/templates.
5
+ * 获取模板目录的绝对路径。
6
+ * 在生产环境 (dist/utils) 中,模板位于 dist/templates
7
+ * 在开发环境 (src/utils) 中,模板位于 src/templates
8
8
  */
9
9
  export function getTemplateDir() {
10
10
  return path.join(__dirname, '..', 'templates');
11
11
  }
12
12
  /**
13
- * Get the absolute path to a specific template file.
14
- * @param fileName The name of the template file (e.g., 'env', 'docker-compose.yml')
13
+ * 获取特定模板文件的绝对路径。
14
+ * @param fileName 模板文件名称 (例如 'env', 'docker-compose.yml')
15
15
  */
16
16
  export function getTemplatePath(fileName) {
17
17
  return path.join(getTemplateDir(), fileName);
@@ -126,10 +126,10 @@
126
126
  "generate.js"
127
127
  ]
128
128
  },
129
- "db": {
129
+ "db:migrate": {
130
130
  "aliases": [],
131
131
  "args": {},
132
- "description": "数据库管理命令",
132
+ "description": "执行数据库迁移 (db:migrate)",
133
133
  "flags": {
134
134
  "env": {
135
135
  "char": "e",
@@ -147,7 +147,7 @@
147
147
  },
148
148
  "hasDynamicHelp": false,
149
149
  "hiddenAliases": [],
150
- "id": "db",
150
+ "id": "db:migrate",
151
151
  "pluginAlias": "nodebbs",
152
152
  "pluginName": "nodebbs",
153
153
  "pluginType": "core",
@@ -158,13 +158,13 @@
158
158
  "dist",
159
159
  "commands",
160
160
  "db",
161
- "index.js"
161
+ "migrate.js"
162
162
  ]
163
163
  },
164
- "db:migrate": {
164
+ "db:push": {
165
165
  "aliases": [],
166
166
  "args": {},
167
- "description": "执行数据库迁移 (db:migrate)",
167
+ "description": "推送数据库 schema (db:push)",
168
168
  "flags": {
169
169
  "env": {
170
170
  "char": "e",
@@ -182,7 +182,7 @@
182
182
  },
183
183
  "hasDynamicHelp": false,
184
184
  "hiddenAliases": [],
185
- "id": "db:migrate",
185
+ "id": "db:push",
186
186
  "pluginAlias": "nodebbs",
187
187
  "pluginName": "nodebbs",
188
188
  "pluginType": "core",
@@ -193,13 +193,13 @@
193
193
  "dist",
194
194
  "commands",
195
195
  "db",
196
- "migrate.js"
196
+ "push.js"
197
197
  ]
198
198
  },
199
- "db:push": {
199
+ "db:reset": {
200
200
  "aliases": [],
201
201
  "args": {},
202
- "description": "推送数据库 schema (db:push)",
202
+ "description": "重置数据库 (db:reset) - 危险操作!",
203
203
  "flags": {
204
204
  "env": {
205
205
  "char": "e",
@@ -217,7 +217,7 @@
217
217
  },
218
218
  "hasDynamicHelp": false,
219
219
  "hiddenAliases": [],
220
- "id": "db:push",
220
+ "id": "db:reset",
221
221
  "pluginAlias": "nodebbs",
222
222
  "pluginName": "nodebbs",
223
223
  "pluginType": "core",
@@ -228,13 +228,13 @@
228
228
  "dist",
229
229
  "commands",
230
230
  "db",
231
- "push.js"
231
+ "reset.js"
232
232
  ]
233
233
  },
234
- "db:reset": {
234
+ "db:seed": {
235
235
  "aliases": [],
236
236
  "args": {},
237
- "description": "重置数据库 (db:reset) - 危险操作!",
237
+ "description": "填充种子数据 (db:seed)",
238
238
  "flags": {
239
239
  "env": {
240
240
  "char": "e",
@@ -252,7 +252,7 @@
252
252
  },
253
253
  "hasDynamicHelp": false,
254
254
  "hiddenAliases": [],
255
- "id": "db:reset",
255
+ "id": "db:seed",
256
256
  "pluginAlias": "nodebbs",
257
257
  "pluginName": "nodebbs",
258
258
  "pluginType": "core",
@@ -263,31 +263,27 @@
263
263
  "dist",
264
264
  "commands",
265
265
  "db",
266
- "reset.js"
266
+ "seed.js"
267
267
  ]
268
268
  },
269
- "db:seed": {
269
+ "pack": {
270
270
  "aliases": [],
271
271
  "args": {},
272
- "description": "填充种子数据 (db:seed)",
272
+ "description": "生成离线部署包",
273
273
  "flags": {
274
- "env": {
275
- "char": "e",
276
- "description": "部署环境 (production, lowmem, basic)",
277
- "name": "env",
274
+ "output": {
275
+ "char": "o",
276
+ "description": "输出文件名",
277
+ "name": "output",
278
+ "default": "nodebbs-offline.tar.gz",
278
279
  "hasDynamicHelp": false,
279
280
  "multiple": false,
280
- "options": [
281
- "production",
282
- "lowmem",
283
- "basic"
284
- ],
285
281
  "type": "option"
286
282
  }
287
283
  },
288
284
  "hasDynamicHelp": false,
289
285
  "hiddenAliases": [],
290
- "id": "db:seed",
286
+ "id": "pack",
291
287
  "pluginAlias": "nodebbs",
292
288
  "pluginName": "nodebbs",
293
289
  "pluginType": "core",
@@ -297,8 +293,8 @@
297
293
  "relativePath": [
298
294
  "dist",
299
295
  "commands",
300
- "db",
301
- "seed.js"
296
+ "pack",
297
+ "index.js"
302
298
  ]
303
299
  },
304
300
  "logs:api": {
@@ -476,6 +472,27 @@
476
472
  "web.js"
477
473
  ]
478
474
  },
475
+ "rebuild": {
476
+ "aliases": [],
477
+ "args": {},
478
+ "description": "重新构建并启动服务 (start --build)",
479
+ "flags": {},
480
+ "hasDynamicHelp": false,
481
+ "hiddenAliases": [],
482
+ "id": "rebuild",
483
+ "pluginAlias": "nodebbs",
484
+ "pluginName": "nodebbs",
485
+ "pluginType": "core",
486
+ "strict": false,
487
+ "enableJsonFlag": false,
488
+ "isESM": true,
489
+ "relativePath": [
490
+ "dist",
491
+ "commands",
492
+ "rebuild",
493
+ "index.js"
494
+ ]
495
+ },
479
496
  "restart": {
480
497
  "aliases": [],
481
498
  "args": {},
@@ -654,7 +671,7 @@
654
671
  "start": {
655
672
  "aliases": [],
656
673
  "args": {},
657
- "description": "启动 NodeBBS (部署模式)",
674
+ "description": "开始部署",
658
675
  "flags": {
659
676
  "env": {
660
677
  "char": "e",
@@ -771,5 +788,5 @@
771
788
  ]
772
789
  }
773
790
  },
774
- "version": "0.0.3"
791
+ "version": "0.0.5"
775
792
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nodebbs",
3
- "description": "A new CLI generated with oclif",
4
- "version": "0.0.3",
3
+ "description": "NodeBBS 论坛系统专业运维工具",
4
+ "version": "0.0.5",
5
5
  "author": "wengqianshan",
6
6
  "bin": {
7
7
  "nodebbs": "./bin/run.js"
@@ -54,13 +54,18 @@
54
54
  "dirname": "nodebbs",
55
55
  "commands": "./dist/commands",
56
56
  "plugins": [
57
- "@oclif/plugin-help",
58
- "@oclif/plugin-plugins"
57
+ "@oclif/plugin-help"
59
58
  ],
60
59
  "topicSeparator": " ",
61
60
  "topics": {
62
61
  "hello": {
63
- "description": "Say hello to the world and others"
62
+ "description": "向世界和他人问好"
63
+ },
64
+ "db": {
65
+ "description": "数据库操作 (备份, 迁移, 种子数据等)"
66
+ },
67
+ "shell": {
68
+ "description": "服务的交互式 Shell"
64
69
  }
65
70
  }
66
71
  },
@@ -1,8 +0,0 @@
1
- import { Command } from '@oclif/core';
2
- export default class Db extends Command {
3
- static description: string;
4
- static flags: {
5
- env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
- };
7
- run(): Promise<void>;
8
- }
@@ -1,17 +0,0 @@
1
- import { Command } from '@oclif/core';
2
- import { execCompose, getComposeFiles } from '../../utils/docker.js';
3
- import { logger } from '../../utils/logger.js';
4
- import { EnvFlag, selectEnvironment } from '../../utils/selection.js';
5
- export default class Db extends Command {
6
- static description = '数据库管理命令';
7
- static flags = {
8
- env: EnvFlag,
9
- };
10
- async run() {
11
- const { flags } = await this.parse(Db);
12
- const env = await selectEnvironment(flags.env);
13
- const { files, isBuiltIn } = await getComposeFiles(env);
14
- logger.info('正在进入数据库 shell...');
15
- await execCompose(files, 'api', ['npm', 'run', 'db:studio'], isBuiltIn);
16
- }
17
- }