nodebbs 0.0.3 → 0.0.4

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
@@ -357,7 +355,7 @@ npx nodebbs shell:db
357
355
  | `make logs` | `npx nodebbs logs` |
358
356
  | `make logs-api` | `npx nodebbs logs:api` |
359
357
  | `make exec-api` | `npx nodebbs shell:api` |
360
- | `make db-studio` | `npx nodebbs db` |
358
+
361
359
  | `make clean-all` | `npx nodebbs stop --volumes` |
362
360
 
363
361
  ## ⚙️ 环境配置
@@ -393,6 +391,16 @@ CORS_ORIGIN=*
393
391
 
394
392
  > **提示**:可以使用 `openssl rand -hex 32` 命令生成安全的随机密钥。
395
393
 
394
+ ### 环境持久化
395
+
396
+ CLI 会自动记住您上次启动的环境:
397
+
398
+ 1. 当您运行 `nodebbs start` 并选择环境(如 `production`)后,CLI 会在当前目录创建 `.nodebbs-env` 文件记录该选择。
399
+ 2. 后续运行 `nodebbs logs`, `nodebbs status` 等命令时,会自动使用该环境,无需再次指定 `-e production`。
400
+ 3. 运行 `nodebbs stop` 成功停止服务后,会自动删除 `.nodebbs-env` 文件。
401
+
402
+ **注意**:如果您需要临时操作其他环境,仍然可以使用 `-e` 参数强制指定,例如 `nodebbs logs -e basic`。
403
+
396
404
  ## 🛠️ 高级用法
397
405
 
398
406
  ### 内置模板
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,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', '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,41 +126,6 @@
126
126
  "generate.js"
127
127
  ]
128
128
  },
129
- "db": {
130
- "aliases": [],
131
- "args": {},
132
- "description": "数据库管理命令",
133
- "flags": {
134
- "env": {
135
- "char": "e",
136
- "description": "部署环境 (production, lowmem, basic)",
137
- "name": "env",
138
- "hasDynamicHelp": false,
139
- "multiple": false,
140
- "options": [
141
- "production",
142
- "lowmem",
143
- "basic"
144
- ],
145
- "type": "option"
146
- }
147
- },
148
- "hasDynamicHelp": false,
149
- "hiddenAliases": [],
150
- "id": "db",
151
- "pluginAlias": "nodebbs",
152
- "pluginName": "nodebbs",
153
- "pluginType": "core",
154
- "strict": true,
155
- "enableJsonFlag": false,
156
- "isESM": true,
157
- "relativePath": [
158
- "dist",
159
- "commands",
160
- "db",
161
- "index.js"
162
- ]
163
- },
164
129
  "db:migrate": {
165
130
  "aliases": [],
166
131
  "args": {},
@@ -476,6 +441,27 @@
476
441
  "web.js"
477
442
  ]
478
443
  },
444
+ "rebuild": {
445
+ "aliases": [],
446
+ "args": {},
447
+ "description": "重新构建并启动服务 (start --build)",
448
+ "flags": {},
449
+ "hasDynamicHelp": false,
450
+ "hiddenAliases": [],
451
+ "id": "rebuild",
452
+ "pluginAlias": "nodebbs",
453
+ "pluginName": "nodebbs",
454
+ "pluginType": "core",
455
+ "strict": false,
456
+ "enableJsonFlag": false,
457
+ "isESM": true,
458
+ "relativePath": [
459
+ "dist",
460
+ "commands",
461
+ "rebuild",
462
+ "index.js"
463
+ ]
464
+ },
479
465
  "restart": {
480
466
  "aliases": [],
481
467
  "args": {},
@@ -511,10 +497,10 @@
511
497
  "index.js"
512
498
  ]
513
499
  },
514
- "shell:api": {
500
+ "start": {
515
501
  "aliases": [],
516
502
  "args": {},
517
- "description": "进入 API 服务 shell",
503
+ "description": "开始部署",
518
504
  "flags": {
519
505
  "env": {
520
506
  "char": "e",
@@ -528,11 +514,18 @@
528
514
  "basic"
529
515
  ],
530
516
  "type": "option"
517
+ },
518
+ "build": {
519
+ "char": "b",
520
+ "description": "重新构建并启动服务 (跳过健康检查和数据初始化)",
521
+ "name": "build",
522
+ "allowNo": false,
523
+ "type": "boolean"
531
524
  }
532
525
  },
533
526
  "hasDynamicHelp": false,
534
527
  "hiddenAliases": [],
535
- "id": "shell:api",
528
+ "id": "start",
536
529
  "pluginAlias": "nodebbs",
537
530
  "pluginName": "nodebbs",
538
531
  "pluginType": "core",
@@ -542,14 +535,14 @@
542
535
  "relativePath": [
543
536
  "dist",
544
537
  "commands",
545
- "shell",
546
- "api.js"
538
+ "start",
539
+ "index.js"
547
540
  ]
548
541
  },
549
- "shell:db": {
542
+ "shell:api": {
550
543
  "aliases": [],
551
544
  "args": {},
552
- "description": "进入数据库 shell (psql)",
545
+ "description": "进入 API 服务 shell",
553
546
  "flags": {
554
547
  "env": {
555
548
  "char": "e",
@@ -567,7 +560,7 @@
567
560
  },
568
561
  "hasDynamicHelp": false,
569
562
  "hiddenAliases": [],
570
- "id": "shell:db",
563
+ "id": "shell:api",
571
564
  "pluginAlias": "nodebbs",
572
565
  "pluginName": "nodebbs",
573
566
  "pluginType": "core",
@@ -578,13 +571,13 @@
578
571
  "dist",
579
572
  "commands",
580
573
  "shell",
581
- "db.js"
574
+ "api.js"
582
575
  ]
583
576
  },
584
- "shell:redis": {
577
+ "shell:db": {
585
578
  "aliases": [],
586
579
  "args": {},
587
- "description": "进入 Redis shell (redis-cli)",
580
+ "description": "进入数据库 shell (psql)",
588
581
  "flags": {
589
582
  "env": {
590
583
  "char": "e",
@@ -602,7 +595,7 @@
602
595
  },
603
596
  "hasDynamicHelp": false,
604
597
  "hiddenAliases": [],
605
- "id": "shell:redis",
598
+ "id": "shell:db",
606
599
  "pluginAlias": "nodebbs",
607
600
  "pluginName": "nodebbs",
608
601
  "pluginType": "core",
@@ -613,13 +606,13 @@
613
606
  "dist",
614
607
  "commands",
615
608
  "shell",
616
- "redis.js"
609
+ "db.js"
617
610
  ]
618
611
  },
619
- "shell:web": {
612
+ "shell:redis": {
620
613
  "aliases": [],
621
614
  "args": {},
622
- "description": "进入 Web 前端 shell",
615
+ "description": "进入 Redis shell (redis-cli)",
623
616
  "flags": {
624
617
  "env": {
625
618
  "char": "e",
@@ -637,7 +630,7 @@
637
630
  },
638
631
  "hasDynamicHelp": false,
639
632
  "hiddenAliases": [],
640
- "id": "shell:web",
633
+ "id": "shell:redis",
641
634
  "pluginAlias": "nodebbs",
642
635
  "pluginName": "nodebbs",
643
636
  "pluginType": "core",
@@ -648,13 +641,13 @@
648
641
  "dist",
649
642
  "commands",
650
643
  "shell",
651
- "web.js"
644
+ "redis.js"
652
645
  ]
653
646
  },
654
- "start": {
647
+ "shell:web": {
655
648
  "aliases": [],
656
649
  "args": {},
657
- "description": "启动 NodeBBS (部署模式)",
650
+ "description": "进入 Web 前端 shell",
658
651
  "flags": {
659
652
  "env": {
660
653
  "char": "e",
@@ -668,18 +661,11 @@
668
661
  "basic"
669
662
  ],
670
663
  "type": "option"
671
- },
672
- "build": {
673
- "char": "b",
674
- "description": "重新构建并启动服务 (跳过健康检查和数据初始化)",
675
- "name": "build",
676
- "allowNo": false,
677
- "type": "boolean"
678
664
  }
679
665
  },
680
666
  "hasDynamicHelp": false,
681
667
  "hiddenAliases": [],
682
- "id": "start",
668
+ "id": "shell:web",
683
669
  "pluginAlias": "nodebbs",
684
670
  "pluginName": "nodebbs",
685
671
  "pluginType": "core",
@@ -689,8 +675,8 @@
689
675
  "relativePath": [
690
676
  "dist",
691
677
  "commands",
692
- "start",
693
- "index.js"
678
+ "shell",
679
+ "web.js"
694
680
  ]
695
681
  },
696
682
  "status": {
@@ -771,5 +757,5 @@
771
757
  ]
772
758
  }
773
759
  },
774
- "version": "0.0.3"
760
+ "version": "0.0.4"
775
761
  }
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.4",
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
- }