nodebbs 0.2.0 → 0.3.0-beta.0

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.
@@ -0,0 +1,120 @@
1
+ # ============================================
2
+ # .dockerignore - Turborepo Monorepo
3
+ # ============================================
4
+ # 作用:优化 Docker 构建上下文
5
+ # 原则:只把源码和必要文件打包进镜像
6
+ # 构建缓存、依赖、日志、运行时数据等全部排除
7
+ # ============================================
8
+
9
+ # ========================
10
+ # Node / pnpm 依赖
11
+ # ========================
12
+ # node_modules 会在 Docker 内重新安装
13
+ node_modules
14
+ # pnpm 全局存储目录
15
+ .pnpm-store
16
+
17
+ # ========================
18
+ # Turborepo 缓存
19
+ # ========================
20
+ .turbo
21
+
22
+ # ========================
23
+ # 构建输出
24
+ # ========================
25
+ .next # Next.js 构建目录
26
+ dist # Fastify / TS 构建目录
27
+ build
28
+ out # Next.js 静态导出目录
29
+ coverage # 测试覆盖率报告
30
+
31
+ # ========================
32
+ # 日志文件
33
+ # ========================
34
+ *.log
35
+ npm-debug.log*
36
+ yarn-debug.log*
37
+ yarn-error.log*
38
+ pnpm-debug.log*
39
+ lerna-debug.log*
40
+
41
+ # ========================
42
+ # 环境变量文件(运行时通过 docker-compose 注入)
43
+ # ========================
44
+ .env* # 排除所有本地环境文件
45
+ !.env.example # 保留示例文件
46
+
47
+ # ========================
48
+ # 测试相关文件
49
+ # ========================
50
+ __tests__ # 单元测试目录
51
+ *.test.* # 测试文件
52
+ *.spec.* # 测试文件
53
+ .nyc_output # 测试覆盖率缓存
54
+
55
+ # ========================
56
+ # 版本控制相关
57
+ # ========================
58
+ .git
59
+ .gitignore
60
+ .gitattributes
61
+
62
+ # ========================
63
+ # 编辑器 / 操作系统临时文件
64
+ # ========================
65
+ .vscode
66
+ .idea
67
+ .DS_Store
68
+ Thumbs.db
69
+ desktop.ini
70
+ *.swp
71
+ *.swo
72
+ *.swn
73
+ .history
74
+
75
+ # ========================
76
+ # CI/CD 和文档
77
+ # ========================
78
+ .github
79
+ .gitlab-ci.yml
80
+ .travis.yml
81
+ .circleci
82
+ docs
83
+ *.md
84
+ !README.md # 保留 README
85
+
86
+ # ========================
87
+ # 缓存 / 临时文件
88
+ # ========================
89
+ .cache
90
+ tmp
91
+ temp
92
+ .temp
93
+ *.tsbuildinfo # TypeScript 构建缓存
94
+
95
+ # ========================
96
+ # 运行时数据(使用 Docker 卷挂载)
97
+ # ========================
98
+ apps/api/uploads/ # 用户上传文件
99
+
100
+ # ========================
101
+ # 本地数据库文件(开发用)
102
+ # ========================
103
+ *.db
104
+ *.sqlite
105
+
106
+ # ========================
107
+ # 数据库 dump(非 migration)
108
+ # ========================
109
+ dump/
110
+ backups/
111
+
112
+ # ========================
113
+ # 进程管理器
114
+ # ========================
115
+ .pm2
116
+
117
+ # ========================
118
+ # docker-compose 文件(不打包进镜像)
119
+ # ========================
120
+ docker-compose*.yml
@@ -44,18 +44,14 @@ APP_URL=http://localhost:3100
44
44
  # ========================================
45
45
  WEB_PORT=3100
46
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
47
+ # Web 服务内部访问 API 的地址
48
+ # 通常保持默认 http://api:7100 即可,走 Docker 内部网络
49
+ SERVER_API_URL=http://api:7100
57
50
 
58
51
  # ========================================
59
52
  # 时区配置
60
53
  # ========================================
61
54
  TZ=Asia/Shanghai
55
+
56
+ API_IMAGE=ghcr.io/aiprojecthub/nodebbs-api:latest
57
+ WEB_IMAGE=ghcr.io/aiprojecthub/nodebbs-web:latest
@@ -4,7 +4,17 @@ import path from 'node:path';
4
4
  import { exists } from 'node:fs';
5
5
  import { promisify } from 'node:util';
6
6
  import { getTemplateDir, getTemplatePath } from './template.js';
7
+ import { getDeploymentMode } from './selection.js';
7
8
  const fileExists = promisify(exists);
9
+ async function getDockerEnv() {
10
+ const mode = await getDeploymentMode();
11
+ const envs = { ...process.env, COMPOSE_LOG_LEVEL: 'ERROR' };
12
+ if (mode === 'source') {
13
+ envs['API_IMAGE'] = 'nodebbs-api:local';
14
+ envs['WEB_IMAGE'] = 'nodebbs-web:local';
15
+ }
16
+ return envs;
17
+ }
8
18
  /**
9
19
  * 获取 Docker Compose 文件路径配置
10
20
  *
@@ -18,21 +28,46 @@ const fileExists = promisify(exists);
18
28
  export async function getComposeFiles(env) {
19
29
  const workDir = process.cwd();
20
30
  let isBuiltIn = false;
31
+ // 1. 确定 Base 文件
21
32
  // 检查当前目录是否存在 docker-compose.yml
22
33
  let baseFile = path.join(workDir, 'docker-compose.yml');
23
- let templateDir = workDir;
34
+ const templateDir = getTemplateDir();
24
35
  if (!await fileExists(baseFile)) {
25
36
  // 使用内置模板
26
37
  isBuiltIn = true;
27
- templateDir = getTemplateDir();
28
38
  baseFile = getTemplatePath('docker-compose.yml');
29
39
  }
30
40
  const files = [baseFile];
31
- if (env === 'production') {
32
- files.push(path.join(templateDir, 'docker-compose.prod.yml'));
41
+ // 2. 确定环境 Override 文件 (production / lowmem)
42
+ if (env === 'production' || env === 'lowmem') {
43
+ const fileName = `docker-compose.${env}.yml`;
44
+ const localOverride = path.join(workDir, fileName);
45
+ const templateOverride = path.join(templateDir, fileName);
46
+ if (await fileExists(localOverride)) {
47
+ files.push(localOverride);
48
+ }
49
+ else if (await fileExists(templateOverride)) {
50
+ // 即使 Base 是本地的,如果本地没有 override,我们也尝试用内置的 override 补全吗?
51
+ // 通常为了混用方便,是可以的。但如果 Base 是本地,通常期望全套本地。
52
+ // 策略:如果 Base 是内置,则一定用内置 Override。如果 Base 是本地,仅当本地也有 Override 时才用?
53
+ // 不,为了方便用户只覆盖 Base 而复用我们的资源配置,如果本地没 Override,可以用内置的。
54
+ files.push(templateOverride);
55
+ }
33
56
  }
34
- else if (env === 'lowmem') {
35
- files.push(path.join(templateDir, 'docker-compose.lowmem.yml'));
57
+ // 3. 确定构建配置 (仅源码模式)
58
+ const mode = await getDeploymentMode();
59
+ if (mode === 'source') {
60
+ const buildFileName = 'docker-compose.build.yml';
61
+ const localBuild = path.join(workDir, buildFileName);
62
+ const templateBuild = path.join(templateDir, buildFileName);
63
+ if (await fileExists(localBuild)) {
64
+ files.push(localBuild);
65
+ }
66
+ else if (isBuiltIn && await fileExists(templateBuild)) {
67
+ // 只有在使用内置 Base 时,才默认加载内置 Build。
68
+ // 如果用户自定义了 Base,通常他们会自己把 build 写进去,或者自己提供 build.yml
69
+ files.push(templateBuild);
70
+ }
36
71
  }
37
72
  return { files, isBuiltIn };
38
73
  }
@@ -68,16 +103,13 @@ export async function runCompose(files, args, isBuiltIn = false) {
68
103
  const composeArgs = files.flatMap(f => ['-f', f]);
69
104
  if (isBuiltIn) {
70
105
  composeArgs.push('--project-directory', process.cwd());
71
- // 将 INIT_DB_PATH 设置为内置 sql 文件
72
- const templateDir = path.dirname(files[0]);
73
- process.env.INIT_DB_PATH = path.join(templateDir, 'init-db.sql');
74
106
  }
75
107
  composeArgs.push(...args);
76
108
  // 使用 stdio: 'inherit' 实时显示输出
77
109
  // 设置 COMPOSE_LOG_LEVEL=ERROR 抑制变量未设置的警告
78
110
  await execa('docker', ['compose', ...composeArgs], {
79
111
  stdio: 'inherit',
80
- env: { ...process.env, COMPOSE_LOG_LEVEL: 'ERROR' }
112
+ env: await getDockerEnv()
81
113
  });
82
114
  }
83
115
  /**
@@ -99,7 +131,7 @@ export async function execCompose(files, service, command, isBuiltIn = false) {
99
131
  composeArgs.push('exec', service, ...command);
100
132
  await execa('docker', ['compose', ...composeArgs], {
101
133
  stdio: 'inherit',
102
- env: { ...process.env, COMPOSE_LOG_LEVEL: 'ERROR' }
134
+ env: await getDockerEnv()
103
135
  });
104
136
  }
105
137
  /**
@@ -121,10 +153,11 @@ export async function waitForHealth(files, isBuiltIn = false) {
121
153
  composeArgs.push('--project-directory', process.cwd());
122
154
  }
123
155
  const pgArgs = [...composeArgs, 'exec', '-T', 'postgres', 'pg_isready', '-U', 'postgres'];
156
+ const envs = await getDockerEnv();
124
157
  let retries = 15;
125
158
  while (retries > 0) {
126
159
  try {
127
- await execa('docker', ['compose', ...pgArgs]);
160
+ await execa('docker', ['compose', ...pgArgs], { env: envs });
128
161
  logger.success('PostgreSQL 已就绪');
129
162
  break;
130
163
  }
@@ -1,2 +1,7 @@
1
+ export declare function getEnvValue(key: string): Promise<string | undefined>;
2
+ export declare function setEnvValue(key: string, value: string): Promise<void>;
3
+ export declare function unsetEnvValue(key: string): Promise<void>;
1
4
  export declare function initEnv(): Promise<void>;
5
+ export declare function initDockerIgnore(): Promise<void>;
6
+ export declare function initImageEnv(): Promise<void>;
2
7
  export declare function checkEnv(envType: 'production' | 'lowmem' | 'basic'): Promise<void>;
package/dist/utils/env.js CHANGED
@@ -6,12 +6,52 @@ import { confirm } from '@inquirer/prompts';
6
6
  import dotenv from 'dotenv';
7
7
  import { getTemplatePath } from './template.js';
8
8
  const fileExists = promisify(exists);
9
- export async function initEnv() {
9
+ export async function getEnvValue(key) {
10
+ if (!await fileExists('.env'))
11
+ return undefined;
12
+ try {
13
+ const content = await fs.readFile('.env', 'utf-8');
14
+ const config = dotenv.parse(content);
15
+ return config[key];
16
+ }
17
+ catch (e) {
18
+ return undefined;
19
+ }
20
+ }
21
+ export async function setEnvValue(key, value) {
22
+ let content = '';
10
23
  if (await fileExists('.env')) {
11
- logger.info('.env 文件已存在,跳过创建');
24
+ content = await fs.readFile('.env', 'utf-8');
25
+ }
26
+ const regex = new RegExp(`^${key}=.*`, 'm');
27
+ if (regex.test(content)) {
28
+ content = content.replace(regex, `${key}=${value}`);
29
+ }
30
+ else {
31
+ if (content && !content.endsWith('\n'))
32
+ content += '\n';
33
+ content += `${key}=${value}\n`;
34
+ }
35
+ await fs.writeFile('.env', content, 'utf-8');
36
+ }
37
+ export async function unsetEnvValue(key) {
38
+ if (!await fileExists('.env'))
12
39
  return;
40
+ let content = await fs.readFile('.env', 'utf-8');
41
+ const regex = new RegExp(`^${key}=.*\n?`, 'm');
42
+ if (regex.test(content)) {
43
+ content = content.replace(regex, '');
44
+ await fs.writeFile('.env', content, 'utf-8');
13
45
  }
14
- logger.info('正在创建 .env 文件...');
46
+ }
47
+ export async function initEnv() {
48
+ const currentEnv = await fileExists('.env') ? dotenv.parse(await fs.readFile('.env', 'utf-8')) : {};
49
+ // 如果已存在关键配置(如 POSTGRES_PASSWORD),则视为无需初始化
50
+ if (currentEnv.POSTGRES_PASSWORD) {
51
+ logger.info('.env 文件已配置,跳过创建');
52
+ return;
53
+ }
54
+ logger.info('正在创建/更新 .env 文件...');
15
55
  // 检查模板文件
16
56
  let templateContent = '';
17
57
  let sourceFile = '';
@@ -54,14 +94,10 @@ export async function initEnv() {
54
94
  message: '设置 API_PORT (后端端口):',
55
95
  default: '7100'
56
96
  });
57
- const defaultApiUrl = `http://localhost:${apiPort}`;
58
- const nextPublicApiUrl = await input({
59
- message: '设置 NEXT_PUBLIC_API_URL (前端访问后端的地址):',
60
- default: defaultApiUrl
61
- });
97
+ const serverApiUrl = `http://api:${apiPort}`;
62
98
  const defaultAppUrl = `http://localhost:${webPort}`;
63
- const nextPublicAppUrl = await input({
64
- message: '设置 NEXT_PUBLIC_APP_URL (应用访问地址):',
99
+ const appUrl = await input({
100
+ message: '设置 APP_URL (应用访问地址):',
65
101
  default: defaultAppUrl
66
102
  });
67
103
  const corsOrigin = await input({
@@ -75,8 +111,8 @@ export async function initEnv() {
75
111
  console.log(`JWT_SECRET: ${jwtSecret.substring(0, 3)}******`);
76
112
  console.log(`WEB_PORT: ${webPort}`);
77
113
  console.log(`API_PORT: ${apiPort}`);
78
- console.log(`NEXT_PUBLIC_API_URL: ${nextPublicApiUrl}`);
79
- console.log(`NEXT_PUBLIC_APP_URL: ${nextPublicAppUrl}`);
114
+ console.log(`APP_URL: ${appUrl}`);
115
+ console.log(`SERVER_API_URL: ${serverApiUrl}`);
80
116
  console.log(`CORS_ORIGIN: ${corsOrigin}`);
81
117
  console.log('==========================================\n');
82
118
  // 3. 确认生成
@@ -98,11 +134,9 @@ export async function initEnv() {
98
134
  'JWT_SECRET': jwtSecret,
99
135
  'WEB_PORT': webPort,
100
136
  'API_PORT': apiPort,
101
- 'NEXT_PUBLIC_API_URL': nextPublicApiUrl,
102
- 'NEXT_PUBLIC_APP_URL': nextPublicAppUrl,
137
+ 'SERVER_API_URL': serverApiUrl,
138
+ 'APP_URL': appUrl,
103
139
  'CORS_ORIGIN': corsOrigin,
104
- // 如果 API_PORT 变了,模板里可能还需要联动修改 APP_URL 用于 OAuth 回调,这里简单处理
105
- 'APP_URL': nextPublicAppUrl
106
140
  };
107
141
  // 针对每个 Key 进行替换。
108
142
  // 策略:查找 `KEY=...` 并替换为 `KEY=newValue`
@@ -118,6 +152,11 @@ export async function initEnv() {
118
152
  }
119
153
  }
120
154
  // 5. 写入文件
155
+ // 如果原文件中有 DEPLOY_ENV 等其他配置,保留它们
156
+ // 简单策略:如果 currentEnv 有 DEPLOY_ENV,确保 newEnv 里也有
157
+ if (currentEnv.DEPLOY_ENV && !newEnv.includes('DEPLOY_ENV=')) {
158
+ newEnv += `\nDEPLOY_ENV=${currentEnv.DEPLOY_ENV}`;
159
+ }
121
160
  await fs.writeFile('.env', newEnv, 'utf-8');
122
161
  // isBuiltIn is not defined in the new logic, need to determine it
123
162
  // based on whether sourceFile was .env.docker.example or a template path
@@ -129,6 +168,41 @@ export async function initEnv() {
129
168
  logger.success(`已从 ${sourceFile} 复制并填充配置生成 .env`);
130
169
  }
131
170
  }
171
+ export async function initDockerIgnore() {
172
+ if (await fileExists('.dockerignore')) {
173
+ return;
174
+ }
175
+ logger.info('正在创建 .dockerignore 文件...');
176
+ const templatePath = getTemplatePath('dockerignore');
177
+ const content = await fs.readFile(templatePath, 'utf-8');
178
+ await fs.writeFile('.dockerignore', content, 'utf-8');
179
+ logger.success('已创建 .dockerignore');
180
+ }
181
+ export async function initImageEnv() {
182
+ const { getDeploymentMode } = await import('./selection.js');
183
+ const mode = await getDeploymentMode();
184
+ logger.info(`当前部署模式: ${mode === 'image' ? '镜像部署' : '源码部署'}`);
185
+ if (mode !== 'image')
186
+ return;
187
+ logger.info('检测到镜像部署模式,请配置镜像地址...');
188
+ // initEnv 已经执行,尝试从 .env 获取默认值
189
+ const currentApiImage = await getEnvValue('API_IMAGE') || 'ghcr.io/aiprojecthub/nodebbs-api:latest';
190
+ const currentWebImage = await getEnvValue('WEB_IMAGE') || 'ghcr.io/aiprojecthub/nodebbs-web:latest';
191
+ let defaultVersion = 'latest';
192
+ const match = currentApiImage.match(/:([^:]+)$/);
193
+ if (match)
194
+ defaultVersion = match[1];
195
+ const { input } = await import('@inquirer/prompts');
196
+ const version = await input({
197
+ message: '设置镜像版本 (默认为 latest):',
198
+ default: defaultVersion
199
+ });
200
+ // 仅替换版本号部分
201
+ const newApiImage = currentApiImage.replace(/:[^:]+$/, `:${version}`);
202
+ const newWebImage = currentWebImage.replace(/:[^:]+$/, `:${version}`);
203
+ await setEnvValue('API_IMAGE', newApiImage);
204
+ await setEnvValue('WEB_IMAGE', newWebImage);
205
+ }
132
206
  export async function checkEnv(envType) {
133
207
  logger.info('正在检查环境配置...');
134
208
  // 加载 .env
@@ -1,6 +1,8 @@
1
1
  export type EnvType = 'production' | 'lowmem' | 'basic';
2
+ export type DeploymentMode = 'source' | 'image';
2
3
  export declare const EnvFlag: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
3
4
  export declare function setStoredEnv(env: EnvType): Promise<void>;
5
+ export declare function getDeploymentMode(): Promise<DeploymentMode>;
4
6
  export declare function clearStoredEnv(): Promise<void>;
5
7
  export declare function selectEnvironment(env?: string, options?: {
6
8
  prompt?: string;
@@ -3,39 +3,37 @@ import { select } from '@inquirer/prompts';
3
3
  import { logger } from './logger.js';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
- const ENV_MARKER_FILE = '.env.lock';
6
+ import { getEnvValue, setEnvValue, unsetEnvValue } from './env.js';
7
7
  export const EnvFlag = Flags.string({
8
8
  char: 'e',
9
9
  description: '部署环境 (production, lowmem, basic)',
10
10
  options: ['production', 'lowmem', 'basic'],
11
11
  });
12
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
- }
13
+ const env = await getEnvValue('DEPLOY_ENV');
14
+ if (env && ['production', 'lowmem', 'basic'].includes(env)) {
15
+ return env;
19
16
  }
20
- catch { }
21
17
  return null;
22
18
  }
23
19
  export async function setStoredEnv(env) {
20
+ await setEnvValue('DEPLOY_ENV', env);
21
+ }
22
+ export async function getDeploymentMode() {
23
+ // 自动检测:检查当前目录下是否有 package.json 且 name='nodebbs-forum'
24
24
  try {
25
- await fs.writeFile(path.resolve(process.cwd(), ENV_MARKER_FILE), env, 'utf-8');
26
- }
27
- catch (error) {
28
- logger.warning(`无法保存环境标记: ${error}`);
25
+ const packageJsonPath = path.resolve(process.cwd(), 'package.json');
26
+ const content = await fs.readFile(packageJsonPath, 'utf-8');
27
+ const pkg = JSON.parse(content);
28
+ if (pkg.name === 'nodebbs-forum') {
29
+ return 'source';
30
+ }
29
31
  }
32
+ catch { }
33
+ return 'image';
30
34
  }
31
35
  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
- }
36
+ await unsetEnvValue('DEPLOY_ENV');
39
37
  }
40
38
  export async function selectEnvironment(env, options = {}) {
41
39
  if (env) {