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 +23 -31
- package/bin/dev.js +6 -1
- package/bin/run.js +6 -1
- package/dist/commands/clean/index.js +4 -4
- package/dist/commands/db/backup.js +10 -10
- package/dist/commands/pack/index.d.ts +8 -0
- package/dist/commands/pack/index.js +150 -0
- package/dist/commands/rebuild/index.d.ts +6 -0
- package/dist/commands/rebuild/index.js +11 -0
- package/dist/commands/restart/index.js +1 -1
- package/dist/commands/start/index.js +10 -9
- package/dist/commands/stop/index.js +4 -2
- package/dist/interactive.d.ts +1 -0
- package/dist/interactive.js +171 -0
- package/dist/utils/docker.js +7 -7
- package/dist/utils/env.js +2 -2
- package/dist/utils/selection.d.ts +2 -0
- package/dist/utils/selection.js +45 -1
- package/dist/utils/template.d.ts +5 -5
- package/dist/utils/template.js +5 -5
- package/oclif.manifest.json +49 -32
- package/package.json +10 -5
- package/dist/commands/db/index.d.ts +0 -8
- package/dist/commands/db/index.js +0 -17
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# NodeBBS CLI
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> NodeBBS 论坛系统专业运维工具
|
|
4
4
|
|
|
5
5
|
[](https://oclif.io)
|
|
6
6
|
[](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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
20
|
+
// 1. 选择环境
|
|
21
21
|
const env = await selectEnvironment(flags.env);
|
|
22
|
-
// 2.
|
|
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
|
-
//
|
|
28
|
+
// 确保绝对路径
|
|
29
29
|
const outputPath = path.resolve(process.cwd(), outputFile);
|
|
30
|
-
// 3.
|
|
30
|
+
// 3. 获取 Compose 文件
|
|
31
31
|
const { files, isBuiltIn } = await getComposeFiles(env);
|
|
32
32
|
logger.info(`正在备份数据库到: ${outputPath}`);
|
|
33
33
|
logger.info('环境: ' + env);
|
|
34
|
-
//
|
|
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.
|
|
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.
|
|
44
|
+
// 5. 运行 pg_dump
|
|
45
45
|
const dumpArgs = [...composeArgs, 'exec', '-T'];
|
|
46
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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,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.
|
|
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 = '
|
|
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.
|
|
26
|
+
// 1. 选择环境
|
|
27
27
|
const env = await selectEnvironment(flags.env);
|
|
28
|
-
|
|
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.
|
|
49
|
+
// 3. 检查 Docker 和环境变量
|
|
49
50
|
await checkDocker();
|
|
50
|
-
// initEnv
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/dist/utils/docker.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
85
|
+
// 等待 Redis
|
|
86
86
|
logger.info('等待 Redis...');
|
|
87
87
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
88
88
|
logger.success('Redis 已就绪');
|
|
89
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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>;
|
package/dist/utils/selection.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/utils/template.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 获取模板目录的绝对路径。
|
|
3
|
+
* 在生产环境 (dist/utils) 中,模板位于 dist/templates。
|
|
4
|
+
* 在开发环境 (src/utils) 中,模板位于 src/templates。
|
|
5
5
|
*/
|
|
6
6
|
export declare function getTemplateDir(): string;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
* @param fileName
|
|
8
|
+
* 获取特定模板文件的绝对路径。
|
|
9
|
+
* @param fileName 模板文件名称 (例如 'env', 'docker-compose.yml')
|
|
10
10
|
*/
|
|
11
11
|
export declare function getTemplatePath(fileName: string): string;
|
package/dist/utils/template.js
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
14
|
-
* @param fileName
|
|
13
|
+
* 获取特定模板文件的绝对路径。
|
|
14
|
+
* @param fileName 模板文件名称 (例如 'env', 'docker-compose.yml')
|
|
15
15
|
*/
|
|
16
16
|
export function getTemplatePath(fileName) {
|
|
17
17
|
return path.join(getTemplateDir(), fileName);
|
package/oclif.manifest.json
CHANGED
|
@@ -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
|
-
"
|
|
161
|
+
"migrate.js"
|
|
162
162
|
]
|
|
163
163
|
},
|
|
164
|
-
"db:
|
|
164
|
+
"db:push": {
|
|
165
165
|
"aliases": [],
|
|
166
166
|
"args": {},
|
|
167
|
-
"description": "
|
|
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:
|
|
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
|
-
"
|
|
196
|
+
"push.js"
|
|
197
197
|
]
|
|
198
198
|
},
|
|
199
|
-
"db:
|
|
199
|
+
"db:reset": {
|
|
200
200
|
"aliases": [],
|
|
201
201
|
"args": {},
|
|
202
|
-
"description": "
|
|
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:
|
|
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
|
-
"
|
|
231
|
+
"reset.js"
|
|
232
232
|
]
|
|
233
233
|
},
|
|
234
|
-
"db:
|
|
234
|
+
"db:seed": {
|
|
235
235
|
"aliases": [],
|
|
236
236
|
"args": {},
|
|
237
|
-
"description": "
|
|
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:
|
|
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
|
-
"
|
|
266
|
+
"seed.js"
|
|
267
267
|
]
|
|
268
268
|
},
|
|
269
|
-
"
|
|
269
|
+
"pack": {
|
|
270
270
|
"aliases": [],
|
|
271
271
|
"args": {},
|
|
272
|
-
"description": "
|
|
272
|
+
"description": "生成离线部署包",
|
|
273
273
|
"flags": {
|
|
274
|
-
"
|
|
275
|
-
"char": "
|
|
276
|
-
"description": "
|
|
277
|
-
"name": "
|
|
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": "
|
|
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
|
-
"
|
|
301
|
-
"
|
|
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": "
|
|
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.
|
|
791
|
+
"version": "0.0.5"
|
|
775
792
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodebbs",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "0.0.
|
|
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": "
|
|
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
|
-
}
|