nodebbs 0.0.2 → 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.
Files changed (62) hide show
  1. package/README.md +103 -111
  2. package/bin/dev.js +6 -1
  3. package/bin/run.js +6 -1
  4. package/dist/commands/clean/index.d.ts +11 -0
  5. package/dist/commands/clean/index.js +93 -0
  6. package/dist/commands/db/backup.d.ts +9 -0
  7. package/dist/commands/db/backup.js +78 -0
  8. package/dist/commands/db/generate.d.ts +3 -0
  9. package/dist/commands/db/generate.js +9 -3
  10. package/dist/commands/db/migrate.d.ts +3 -0
  11. package/dist/commands/db/migrate.js +8 -2
  12. package/dist/commands/db/push.d.ts +3 -0
  13. package/dist/commands/db/push.js +8 -2
  14. package/dist/commands/db/reset.d.ts +3 -0
  15. package/dist/commands/db/reset.js +10 -4
  16. package/dist/commands/db/seed.d.ts +3 -0
  17. package/dist/commands/db/seed.js +9 -3
  18. package/dist/commands/logs/api.d.ts +3 -0
  19. package/dist/commands/logs/api.js +8 -2
  20. package/dist/commands/logs/db.d.ts +3 -0
  21. package/dist/commands/logs/db.js +8 -2
  22. package/dist/commands/logs/index.d.ts +3 -0
  23. package/dist/commands/logs/index.js +8 -2
  24. package/dist/commands/logs/redis.d.ts +3 -0
  25. package/dist/commands/logs/redis.js +8 -2
  26. package/dist/commands/logs/web.d.ts +3 -0
  27. package/dist/commands/logs/web.js +9 -3
  28. package/dist/commands/{db → rebuild}/index.d.ts +2 -1
  29. package/dist/commands/rebuild/index.js +11 -0
  30. package/dist/commands/restart/index.d.ts +3 -0
  31. package/dist/commands/restart/index.js +10 -2
  32. package/dist/commands/shell/api.d.ts +3 -0
  33. package/dist/commands/shell/api.js +9 -3
  34. package/dist/commands/shell/db.d.ts +3 -0
  35. package/dist/commands/shell/db.js +9 -3
  36. package/dist/commands/shell/redis.d.ts +3 -0
  37. package/dist/commands/shell/redis.js +9 -3
  38. package/dist/commands/shell/web.d.ts +3 -0
  39. package/dist/commands/shell/web.js +9 -3
  40. package/dist/commands/{dev → start}/index.d.ts +2 -2
  41. package/dist/commands/start/index.js +111 -0
  42. package/dist/commands/status/index.d.ts +3 -0
  43. package/dist/commands/status/index.js +8 -2
  44. package/dist/commands/stop/index.d.ts +1 -0
  45. package/dist/commands/stop/index.js +9 -1
  46. package/dist/interactive.d.ts +1 -0
  47. package/dist/interactive.js +171 -0
  48. package/dist/templates/env +61 -0
  49. package/dist/utils/docker.js +10 -18
  50. package/dist/utils/env.js +20 -10
  51. package/dist/utils/selection.d.ts +7 -0
  52. package/dist/utils/selection.js +68 -0
  53. package/dist/utils/template.d.ts +11 -0
  54. package/dist/utils/template.js +18 -0
  55. package/oclif.manifest.json +367 -90
  56. package/package.json +30 -25
  57. package/dist/commands/db/index.js +0 -11
  58. package/dist/commands/deploy/index.d.ts +0 -8
  59. package/dist/commands/deploy/index.js +0 -95
  60. package/dist/commands/dev/index.js +0 -59
  61. package/dist/commands/setup/index.d.ts +0 -5
  62. package/dist/commands/setup/index.js +0 -12
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ # ========================================
2
+ # NodeBBS Docker Compose 环境变量配置
3
+ # ========================================
4
+
5
+ # 应用名称
6
+ APP_NAME=nodebbs
7
+
8
+ # ========================================
9
+ # 数据库配置
10
+ # ========================================
11
+ POSTGRES_PASSWORD=your_secure_postgres_password_here
12
+ POSTGRES_DB=nodebbs
13
+ POSTGRES_PORT=5432
14
+
15
+ # ========================================
16
+ # Redis 配置
17
+ # ========================================
18
+ REDIS_PASSWORD=your_secure_redis_password_here
19
+ REDIS_PORT=6379
20
+
21
+ # ========================================
22
+ # API 服务配置
23
+ # ========================================
24
+ API_PORT=7100
25
+
26
+ # 用户缓存 TTL(秒)
27
+ # 开发环境: 30-60, 生产环境: 120-300
28
+ USER_CACHE_TTL=120
29
+
30
+ # JWT 配置
31
+ # 使用 `openssl rand -base64 32` 生成安全的密钥
32
+ JWT_SECRET=change-this-to-a-secure-random-string-in-production
33
+ JWT_ACCESS_TOKEN_EXPIRES_IN=1y
34
+
35
+ # CORS 配置
36
+ # 生产环境建议设置为具体的域名,例如: https://yourdomain.com
37
+ CORS_ORIGIN=*
38
+
39
+ # 应用 URL(OAuth 回调使用)
40
+ APP_URL=http://localhost:3100
41
+
42
+ # ========================================
43
+ # Web 前端配置
44
+ # ========================================
45
+ WEB_PORT=3100
46
+
47
+ # API 地址(公网访问地址)
48
+ # 使用 docker 部署必须指定 IP 或域名(避免SSR跨容器通信问题)
49
+ # 开发环境: http://192.168.0.100:7100
50
+ # 生产环境: https://api.yourdomain.com
51
+ NEXT_PUBLIC_API_URL=http://192.168.0.100:7100
52
+
53
+ # 应用 URL(公网访问地址)
54
+ # 开发环境: http://localhost:3100
55
+ # 生产环境: https://yourdomain.com
56
+ NEXT_PUBLIC_APP_URL=http://localhost:3100
57
+
58
+ # ========================================
59
+ # 时区配置
60
+ # ========================================
61
+ TZ=Asia/Shanghai
@@ -3,27 +3,19 @@ import { logger } from './logger.js';
3
3
  import path from 'node:path';
4
4
  import { exists } from 'node:fs';
5
5
  import { promisify } from 'node:util';
6
- import { fileURLToPath } from 'node:url';
6
+ import { getTemplateDir, getTemplatePath } from './template.js';
7
7
  const fileExists = promisify(exists);
8
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
8
  export async function getComposeFiles(env) {
10
9
  const workDir = process.cwd();
11
10
  let isBuiltIn = false;
12
- // Check current directory for docker-compose.yml
11
+ // 检查当前目录是否存在 docker-compose.yml
13
12
  let baseFile = path.join(workDir, 'docker-compose.yml');
14
13
  let templateDir = workDir;
15
14
  if (!await fileExists(baseFile)) {
16
- // Use built-in templates
15
+ // 使用内置模板
17
16
  isBuiltIn = true;
18
- // In production (dist/utils/docker.js), templates are in dist/templates
19
- // __dirname is dist/utils, so ../templates points to dist/templates
20
- templateDir = path.join(__dirname, '..', 'templates');
21
- baseFile = path.join(templateDir, 'docker-compose.yml');
22
- // Verify the built-in template exists
23
- if (!await fileExists(baseFile)) {
24
- logger.error('内置模板未找到,请确保 CLI 正确安装。');
25
- throw new Error('Built-in templates not found');
26
- }
17
+ templateDir = getTemplateDir();
18
+ baseFile = getTemplatePath('docker-compose.yml');
27
19
  }
28
20
  const files = [baseFile];
29
21
  if (env === 'production') {
@@ -50,12 +42,12 @@ export async function runCompose(files, args, isBuiltIn = false) {
50
42
  const composeArgs = files.flatMap(f => ['-f', f]);
51
43
  if (isBuiltIn) {
52
44
  composeArgs.push('--project-directory', process.cwd());
53
- // Set INIT_DB_PATH to the built-in sql file
45
+ // INIT_DB_PATH 设置为内置 sql 文件
54
46
  const templateDir = path.dirname(files[0]);
55
47
  process.env.INIT_DB_PATH = path.join(templateDir, 'init-db.sql');
56
48
  }
57
49
  composeArgs.push(...args);
58
- // Use stdio: 'inherit' to show output in real-time
50
+ // 使用 stdio: 'inherit' 实时显示输出
59
51
  await execa('docker', ['compose', ...composeArgs], { stdio: 'inherit' });
60
52
  }
61
53
  export async function execCompose(files, service, command, isBuiltIn = false) {
@@ -68,7 +60,7 @@ export async function execCompose(files, service, command, isBuiltIn = false) {
68
60
  }
69
61
  export async function waitForHealth(files, isBuiltIn = false) {
70
62
  logger.info('正在等待服务就绪...');
71
- // Wait for Postgres
63
+ // 等待 Postgres
72
64
  logger.info('等待 PostgreSQL...');
73
65
  const composeArgs = files.flatMap(f => ['-f', f]);
74
66
  if (isBuiltIn) {
@@ -90,11 +82,11 @@ export async function waitForHealth(files, isBuiltIn = false) {
90
82
  if (retries === 0) {
91
83
  logger.warning('PostgreSQL 可能尚未就绪');
92
84
  }
93
- // Wait for Redis
85
+ // 等待 Redis
94
86
  logger.info('等待 Redis...');
95
87
  await new Promise(resolve => setTimeout(resolve, 5000));
96
88
  logger.success('Redis 已就绪');
97
- // Wait for API
89
+ // 等待 API
98
90
  logger.info('等待 API 服务...');
99
91
  await new Promise(resolve => setTimeout(resolve, 10000));
100
92
  logger.success('API 服务已就绪');
package/dist/utils/env.js CHANGED
@@ -4,6 +4,7 @@ import { promisify } from 'node:util';
4
4
  import { logger } from './logger.js';
5
5
  import { confirm } from '@inquirer/prompts';
6
6
  import dotenv from 'dotenv';
7
+ import { getTemplatePath } from './template.js';
7
8
  const fileExists = promisify(exists);
8
9
  export async function initEnv() {
9
10
  if (await fileExists('.env')) {
@@ -11,8 +12,24 @@ export async function initEnv() {
11
12
  return;
12
13
  }
13
14
  logger.info('正在创建 .env 文件...');
15
+ let sourceFile = '';
16
+ let isBuiltIn = false;
14
17
  if (await fileExists('.env.docker.example')) {
15
- await fs.copyFile('.env.docker.example', '.env');
18
+ sourceFile = '.env.docker.example';
19
+ }
20
+ else {
21
+ // 使用内置模板
22
+ sourceFile = getTemplatePath('env');
23
+ isBuiltIn = true;
24
+ }
25
+ if (sourceFile) {
26
+ await fs.copyFile(sourceFile, '.env');
27
+ if (isBuiltIn) {
28
+ logger.success('已使用内置模板创建 .env');
29
+ }
30
+ else {
31
+ logger.success(`已从 ${sourceFile} 复制 .env`);
32
+ }
16
33
  logger.warning('请编辑 .env 文件并修改以下配置:');
17
34
  logger.warning(' - POSTGRES_PASSWORD (数据库密码)');
18
35
  logger.warning(' - REDIS_PASSWORD (Redis 密码)');
@@ -27,19 +44,12 @@ export async function initEnv() {
27
44
  }
28
45
  }
29
46
  else {
30
- // If .env.docker.example doesn't exist, maybe we are in the wrong directory or need to look in project/
31
- if (await fileExists('project/.env.docker.example')) {
32
- await fs.copyFile('project/.env.docker.example', '.env');
33
- logger.success('已从 project/.env.docker.example 复制 .env');
34
- }
35
- else {
36
- logger.warning('未找到 .env.docker.example!跳过 .env 创建。');
37
- }
47
+ logger.warning('未找到 .env.docker.example 或内置模板!跳过 .env 创建。');
38
48
  }
39
49
  }
40
50
  export async function checkEnv(envType) {
41
51
  logger.info('正在检查环境配置...');
42
- // Load .env
52
+ // 加载 .env
43
53
  const envConfig = dotenv.config().parsed || {};
44
54
  let warnings = 0;
45
55
  let errors = 0;
@@ -0,0 +1,7 @@
1
+ export type EnvType = 'production' | 'lowmem' | 'basic';
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>;
5
+ export declare function selectEnvironment(env?: string, options?: {
6
+ prompt?: string;
7
+ }): Promise<EnvType>;
@@ -0,0 +1,68 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { select } from '@inquirer/prompts';
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';
7
+ export const EnvFlag = Flags.string({
8
+ char: 'e',
9
+ description: '部署环境 (production, lowmem, basic)',
10
+ options: ['production', 'lowmem', 'basic'],
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
+ }
40
+ export async function selectEnvironment(env, options = {}) {
41
+ if (env) {
42
+ // oclif 选项验证会处理有效值,但仍需类型转换
43
+ return env;
44
+ }
45
+ // 检查已存储的环境配置
46
+ const storedEnv = await getStoredEnv();
47
+ if (storedEnv) {
48
+ logger.info(`检测到运行环境: ${storedEnv}`);
49
+ return storedEnv;
50
+ }
51
+ const selected = await select({
52
+ message: options.prompt || '请选择运行环境:',
53
+ choices: [
54
+ { name: '标准生产环境 (2C4G+) [推荐]', value: 'production' },
55
+ { name: '低配环境 (1C1G/1C2G)', value: 'lowmem' },
56
+ { name: '基础环境 (仅用于测试)', value: 'basic' },
57
+ { name: '❌ 取消', value: '__CANCEL__' },
58
+ ],
59
+ loop: true,
60
+ });
61
+ if (selected === '__CANCEL__') {
62
+ const error = new Error('用户取消操作');
63
+ error.name = 'CancelError';
64
+ throw error;
65
+ }
66
+ logger.info(`已选择环境: ${selected}`);
67
+ return selected;
68
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 获取模板目录的绝对路径。
3
+ * 在生产环境 (dist/utils) 中,模板位于 dist/templates。
4
+ * 在开发环境 (src/utils) 中,模板位于 src/templates。
5
+ */
6
+ export declare function getTemplateDir(): string;
7
+ /**
8
+ * 获取特定模板文件的绝对路径。
9
+ * @param fileName 模板文件名称 (例如 'env', 'docker-compose.yml')
10
+ */
11
+ export declare function getTemplatePath(fileName: string): string;
@@ -0,0 +1,18 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
4
+ /**
5
+ * 获取模板目录的绝对路径。
6
+ * 在生产环境 (dist/utils) 中,模板位于 dist/templates。
7
+ * 在开发环境 (src/utils) 中,模板位于 src/templates。
8
+ */
9
+ export function getTemplateDir() {
10
+ return path.join(__dirname, '..', 'templates');
11
+ }
12
+ /**
13
+ * 获取特定模板文件的绝对路径。
14
+ * @param fileName 模板文件名称 (例如 'env', 'docker-compose.yml')
15
+ */
16
+ export function getTemplatePath(fileName) {
17
+ return path.join(getTemplateDir(), fileName);
18
+ }