Add PM2 deployment support

This commit is contained in:
lofyer
2026-08-24 17:59:49 +08:00
parent 5ff80caf7e
commit 63bbbac922
16 changed files with 450 additions and 55 deletions
+13
View File
@@ -1,6 +1,19 @@
# 可直接复制为 .env 用于本地 Node / PM2 启动。
# 公网部署前必须修改初始管理员凭据和对外地址。
NODE_ENV=development
HOST=127.0.0.1
PORT=3000
DATA_DIRECTORY=./data
# 留空时自动生成并保存到 DATA_DIRECTORY/.app-secret。
APP_SECRET=
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=change-this-admin-password
INITIAL_ADMIN_NAME=Administrator
SESSION_TTL_HOURS=12
TRUST_PROXY=false
PUBLIC_RATE_LIMIT_PER_HOUR=20
PUBLIC_INSTALLATION_LIMIT_PER_HOUR=8
MAX_SCREENSHOT_BYTES=5242880
# Docker Compose 使用此域名;Node production 改用 PUBLIC_ORIGIN。
FEEDBACK_DOMAIN=feedback.example.com
# PUBLIC_ORIGIN=https://feedback.example.com
+38 -29
View File
@@ -56,31 +56,10 @@ cp .env.example .env
编辑 `.env`:
- 将 `FEEDBACK_DOMAIN` 改为真实域名
- 用至少 32 字节的随机值替换 `APP_SECRET`
- 设置 `INITIAL_ADMIN_EMAIL` 和 `INITIAL_ADMIN_PASSWORD`
- 可按需设置 `INITIAL_ADMIN_NAME`
可用以下方式生成 secret:
```sh
APP_SECRET="$(openssl rand -hex 32)"
sed -i "s|^APP_SECRET=.*|APP_SECRET=${APP_SECRET}|" .env
unset APP_SECRET
chmod 600 .env
```
构建镜像并创建首个管理员:
```sh
docker compose build
read -rsp "Administrator password: " PRODUCT_ISSUE_ADMIN_PASSWORD
echo
export PRODUCT_ISSUE_ADMIN_PASSWORD
docker compose run --rm \
-e PRODUCT_ISSUE_ADMIN_PASSWORD \
app node dist/server/server/cli/create-admin.js \
--email admin@example.com \
--name "MesaLogo Admin"
unset PRODUCT_ISSUE_ADMIN_PASSWORD
```
`APP_SECRET` 可以留空,应用会在数据目录生成并持久化随机 secret。
启动服务:
@@ -90,17 +69,23 @@ docker compose ps
curl "https://feedback.example.com/health"
```
首次启动只在数据库没有管理员时根据 `.env` 创建初始管理员,不会在后续
重启时重置密码。创建完成后建议从 `.env` 删除
`INITIAL_ADMIN_PASSWORD`。
将示例域名替换为 `.env` 中的真实域名。管理后台位于
`https://你的域名/admin/`。
> 管理员创建命令也用于密码重置。重置后,该管理员的所有既有会话会立即
> 失效。不要把密码写入 `.env`、命令参数或 Git。
> `admin:create` 命令仍可用于创建或重置其他管理员。重置后,该管理员的
> 所有既有会话会立即失效。
## 本地开发
要求 Node.js 24 或更新版本。
```sh
cp .env.example .env
# 示例配置可直接启动;对外部署前必须修改初始管理员凭据
npm ci
npm run typecheck
npm test
@@ -108,9 +93,12 @@ npm run lint
npm run build
```
开发服务器需要通过进程环境提供 `APP_SECRET` 和其他配置。后端默认监听
`127.0.0.1:3000`,管理后台 Vite 服务默认监听 `127.0.0.1:5174` 并代理
`/api`:
Node 后端和管理员 CLI 会自动读取项目根目录的 `.env`。已由 shell、PM2
或系统服务注入的同名环境变量优先,不会被文件中的值覆盖。可通过
`ENV_FILE=/path/to/file` 指定其他配置文件。
后端默认监听 `127.0.0.1:3000`,管理后台 Vite 服务默认监听
`127.0.0.1:5174` 并代理 `/api`:
```sh
npm run dev
@@ -118,6 +106,27 @@ npm run dev
npm run dev:admin
```
## PM2 运行
没有 Docker 时可使用 PM2 直接管理构建后的 Node 进程:
```sh
npm install --global pm2
cp .env.example .env
# 示例配置可直接启动;生产环境必须先修改初始管理员凭据
npm run pm2:start
pm2 save
```
初始管理员仅在数据库为空时创建。确认可以登录后,建议删除 `.env` 中的
`INITIAL_ADMIN_PASSWORD`,再执行 `npm run pm2:reload`。未设置
`APP_SECRET` 时,应用会把自动生成的 secret 保存到
`DATA_DIRECTORY/.app-secret`。
更新代码后运行 `npm run pm2:reload`,查看日志使用
`pm2 logs product-issue`。默认访问地址为
`http://127.0.0.1:3000/admin/`。
## 文档
- [公共 API 契约](docs/api.md)
+4 -1
View File
@@ -22,7 +22,10 @@ services:
PORT: 3000
PUBLIC_ORIGIN: https://${FEEDBACK_DOMAIN:?FEEDBACK_DOMAIN is required}
DATA_DIRECTORY: /app/data
APP_SECRET: ${APP_SECRET:?APP_SECRET is required}
APP_SECRET: ${APP_SECRET:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_NAME: ${INITIAL_ADMIN_NAME:-Administrator}
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12}
TRUST_PROXY: "true"
PUBLIC_RATE_LIMIT_PER_HOUR: ${PUBLIC_RATE_LIMIT_PER_HOUR:-20}
+73 -2
View File
@@ -2,12 +2,23 @@
## 配置
Docker Compose 从项目根目录 `.env` 读取以下值:
Docker Compose、直接运行的 Node 服务和管理员 CLI 都会从项目根目录
`.env` 读取配置。已由 shell、PM2 或系统服务注入的同名变量优先。设置
`ENV_FILE=/path/to/file` 可改用其他配置文件;显式指定的文件不存在时,
进程会拒绝启动。
| 变量 | 说明 | 默认值 |
| --- | --- | --- |
| `NODE_ENV` | 运行环境:`development`、`test` 或 `production` | `development` |
| `HOST` | Node 服务监听地址 | `127.0.0.1` |
| `PORT` | Node 服务监听端口 | `3000` |
| `PUBLIC_ORIGIN` | production 对外 HTTPS origin | production 必填 |
| `DATA_DIRECTORY` | SQLite 与附件目录 | `./data` |
| `FEEDBACK_DOMAIN` | Caddy 申请证书并对外服务的域名 | 必填 |
| `APP_SECRET` | 会话、IP 和安装标识 digest 的主 secret,至少 32 字符 | 必填 |
| `APP_SECRET` | 会话、IP 和安装标识 digest 的主 secret,不限制长度 | 自动生成并持久化 |
| `INITIAL_ADMIN_EMAIL` | 数据库为空时创建的初始管理员邮箱 | 可选 |
| `INITIAL_ADMIN_PASSWORD` | 初始管理员密码,12–128 字符 | 可选 |
| `INITIAL_ADMIN_NAME` | 初始管理员显示名 | `Administrator` |
| `SESSION_TTL_HOURS` | 管理员会话时长 | `12` |
| `PUBLIC_RATE_LIMIT_PER_HOUR` | 单 IP 每小时公开提交上限 | `20` |
| `PUBLIC_INSTALLATION_LIMIT_PER_HOUR` | 单安装实例每小时公开提交上限 | `8` |
@@ -16,10 +27,70 @@ Docker Compose 从项目根目录 `.env` 读取以下值:
`.env` 包含 secret,不得提交到 Git 或复制到工单、聊天和日志中。建议在
密码管理器中保存一份加密副本,并在主机上执行 `chmod 600 .env`。
未设置或留空 `APP_SECRET` 时,应用会生成随机值并以权限 `0600` 保存到
`DATA_DIRECTORY/.app-secret`。后续启动会复用该文件。应将此文件与
SQLite 和附件一起备份,否则恢复后现有管理员会话将失效。
`INITIAL_ADMIN_EMAIL` 和 `INITIAL_ADMIN_PASSWORD` 必须同时设置。应用仅在
管理员表为空时创建该账号,后续重启不会同步或重置密码。确认初始账号
可登录后,应从 `.env` 删除 `INITIAL_ADMIN_PASSWORD`。
Compose 会从 `FEEDBACK_DOMAIN` 生成
`PUBLIC_ORIGIN=https://FEEDBACK_DOMAIN`。直接运行 Node 服务时,
production 环境仍必须显式提供只含 HTTPS origin 的 `PUBLIC_ORIGIN`。
## PM2 部署
没有 Docker 时,可通过 Node.js 24+ 和 PM2 启动服务。先安装依赖并准备
配置:
```sh
npm ci
npm install --global pm2
cp .env.example .env
```
复制后的示例配置可以直接以 development 模式启动,初始账号为
`admin@example.com`,示例密码仅用于首次本地运行。公网部署前必须修改
初始管理员凭据并配置 production 对外地址:
```dotenv
NODE_ENV=production
HOST=127.0.0.1
PORT=3000
PUBLIC_ORIGIN=https://feedback.example.com
DATA_DIRECTORY=./data
APP_SECRET=
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=replace-with-a-secure-password
INITIAL_ADMIN_NAME=Administrator
```
构建并启动:
```sh
npm run pm2:start
pm2 save
pm2 status
curl --fail "http://127.0.0.1:3000/health"
```
登录成功后删除 `.env` 中的 `INITIAL_ADMIN_PASSWORD`,再执行
`npm run pm2:reload`。数据库已有管理员时,即使保留这些变量也不会重置
账号,但不应长期把明文密码保存在主机配置中。
`ecosystem.config.cjs` 固定使用单进程 fork 模式,避免多个进程同时管理单机
SQLite 和本地附件。更新代码后执行:
```sh
npm ci
npm run pm2:reload
pm2 logs product-issue --lines 100
```
PM2 只管理 Node 进程,不提供 TLS。公网部署仍须在前方配置 Caddy、Nginx
或等效 HTTPS 反向代理,并且只让代理访问监听端口。
## 网络与 TLS
- DNS A/AAAA 记录必须指向主机。
+15
View File
@@ -0,0 +1,15 @@
module.exports = {
apps: [
{
name: 'product-issue',
script: 'dist/server/server/main.js',
cwd: __dirname,
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
max_memory_restart: '512M',
kill_timeout: 10_000
}
]
}
+2 -1
View File
@@ -9,7 +9,8 @@ export default tseslint.config(
'node_modules/**',
'coverage/**',
'data/**',
'eslint.config.js'
'eslint.config.js',
'ecosystem.config.cjs'
]
},
eslint.configs.recommended,
+7 -7
View File
@@ -2224,13 +2224,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.0.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.0.tgz",
"integrity": "sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg==",
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.8.0"
"undici-types": "~7.18.0"
}
},
"node_modules/@types/react": {
@@ -5166,9 +5166,9 @@
}
},
"node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
+3
View File
@@ -14,6 +14,9 @@
"build:admin": "vite build",
"build:server": "tsc -p tsconfig.server.json",
"start": "node dist/server/server/main.js",
"pm2:start": "npm run build && pm2 start ecosystem.config.cjs",
"pm2:reload": "npm run build && pm2 reload ecosystem.config.cjs --update-env",
"pm2:stop": "pm2 stop product-issue",
"admin:create": "tsx src/server/cli/create-admin.ts",
"admin:create:prod": "node dist/server/server/cli/create-admin.js",
"typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.admin.json && tsc --noEmit -p tsconfig.test.json",
+15
View File
@@ -86,6 +86,21 @@ export class AuthService {
})()
}
async createInitialAdmin(input: {
email: string
displayName: string
password: string
}): Promise<boolean> {
const existingAdmin = this.database
.prepare('SELECT 1 FROM admins LIMIT 1')
.get()
if (existingAdmin) {
return false
}
await this.createAdmin(input)
return true
}
async login(email: string, password: string): Promise<CreatedSession | undefined> {
this.pruneExpiredSessionsIfDue()
const admin = this.database
+2
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { AuthService } from '../auth-service.js'
import { loadConfig } from '../config.js'
import { openDatabase } from '../database.js'
import { loadEnvironmentFile } from '../environment.js'
const argumentsSchema = z.object({
email: z.email().max(254),
@@ -27,6 +28,7 @@ function parseArguments(input: string[]): { email: string; name: string } {
process.umask(0o077)
loadEnvironmentFile()
const config = loadConfig()
const password = process.env['PRODUCT_ISSUE_ADMIN_PASSWORD']
if (!password || password.length < 12 || password.length > 128) {
+109 -11
View File
@@ -1,3 +1,9 @@
import { randomBytes } from 'node:crypto'
import {
mkdirSync,
readFileSync,
writeFileSync
} from 'node:fs'
import { resolve } from 'node:path'
import { z } from 'zod'
@@ -5,6 +11,18 @@ const booleanEnvironmentSchema = z
.enum(['true', 'false'])
.transform((value) => value === 'true')
const optionalEnvironmentString = <T extends z.ZodType>(
schema: T
) =>
z
.preprocess(
(value) =>
typeof value === 'string' && value.trim() === ''
? undefined
: value,
schema
)
const configSchema = z
.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
@@ -12,14 +30,16 @@ const configSchema = z
PORT: z.coerce.number().int().min(1).max(65_535).default(3_000),
PUBLIC_ORIGIN: z.url().optional(),
DATA_DIRECTORY: z.string().min(1).default('./data'),
APP_SECRET: z
.string()
.min(32)
.refine(
(value) =>
value !== 'replace-with-at-least-32-random-characters',
'APP_SECRET must be a random production secret'
),
APP_SECRET: optionalEnvironmentString(z.string().optional()),
INITIAL_ADMIN_EMAIL: optionalEnvironmentString(
z.email().max(254).optional()
),
INITIAL_ADMIN_PASSWORD: optionalEnvironmentString(
z.string().min(12).max(128).optional()
),
INITIAL_ADMIN_NAME: optionalEnvironmentString(
z.string().trim().min(1).max(120).optional()
),
SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(168).default(12),
TRUST_PROXY: booleanEnvironmentSchema.default(false),
PUBLIC_RATE_LIMIT_PER_HOUR: z.coerce
@@ -43,6 +63,17 @@ const configSchema = z
})
.strict()
.superRefine((value, context) => {
if (
Boolean(value.INITIAL_ADMIN_EMAIL) !==
Boolean(value.INITIAL_ADMIN_PASSWORD)
) {
context.addIssue({
code: 'custom',
path: ['INITIAL_ADMIN_EMAIL'],
message:
'INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD must be configured together'
})
}
if (value.NODE_ENV === 'production' && !value.PUBLIC_ORIGIN) {
context.addIssue({
code: 'custom',
@@ -89,6 +120,59 @@ export type AppConfig = {
publicRateLimitPerHour: number
publicInstallationLimitPerHour: number
maxScreenshotBytes: number
initialAdmin?: {
email: string
displayName: string
password: string
}
}
function resolveAppSecret(
configuredSecret: string | undefined,
dataDirectory: string
): string {
if (configuredSecret !== undefined) {
return configuredSecret
}
mkdirSync(dataDirectory, { recursive: true, mode: 0o700 })
const secretFile = resolve(dataDirectory, '.app-secret')
try {
const savedSecret = readFileSync(secretFile, 'utf8').trim()
if (savedSecret) {
return savedSecret
}
} catch (error) {
if (
!(error instanceof Error) ||
!('code' in error) ||
error.code !== 'ENOENT'
) {
throw error
}
}
const generatedSecret = randomBytes(32).toString('base64url')
try {
writeFileSync(secretFile, `${generatedSecret}\n`, {
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
})
return generatedSecret
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'EEXIST'
) {
const savedSecret = readFileSync(secretFile, 'utf8').trim()
if (savedSecret) {
return savedSecret
}
}
throw error
}
}
export function loadConfig(
@@ -101,6 +185,9 @@ export function loadConfig(
PUBLIC_ORIGIN: environment['PUBLIC_ORIGIN'],
DATA_DIRECTORY: environment['DATA_DIRECTORY'],
APP_SECRET: environment['APP_SECRET'],
INITIAL_ADMIN_EMAIL: environment['INITIAL_ADMIN_EMAIL'],
INITIAL_ADMIN_PASSWORD: environment['INITIAL_ADMIN_PASSWORD'],
INITIAL_ADMIN_NAME: environment['INITIAL_ADMIN_NAME'],
SESSION_TTL_HOURS: environment['SESSION_TTL_HOURS'],
TRUST_PROXY: environment['TRUST_PROXY'],
PUBLIC_RATE_LIMIT_PER_HOUR:
@@ -109,6 +196,7 @@ export function loadConfig(
environment['PUBLIC_INSTALLATION_LIMIT_PER_HOUR'],
MAX_SCREENSHOT_BYTES: environment['MAX_SCREENSHOT_BYTES']
})
const dataDirectory = resolve(parsed.DATA_DIRECTORY)
return {
nodeEnv: parsed.NODE_ENV,
host: parsed.HOST,
@@ -116,13 +204,23 @@ export function loadConfig(
...(parsed.PUBLIC_ORIGIN
? { publicOrigin: new URL(parsed.PUBLIC_ORIGIN).origin }
: {}),
dataDirectory: resolve(parsed.DATA_DIRECTORY),
appSecret: parsed.APP_SECRET,
dataDirectory,
appSecret: resolveAppSecret(parsed.APP_SECRET, dataDirectory),
sessionTtlMs: parsed.SESSION_TTL_HOURS * 60 * 60 * 1_000,
trustProxy: parsed.TRUST_PROXY,
publicRateLimitPerHour: parsed.PUBLIC_RATE_LIMIT_PER_HOUR,
publicInstallationLimitPerHour:
parsed.PUBLIC_INSTALLATION_LIMIT_PER_HOUR,
maxScreenshotBytes: parsed.MAX_SCREENSHOT_BYTES
maxScreenshotBytes: parsed.MAX_SCREENSHOT_BYTES,
...(parsed.INITIAL_ADMIN_EMAIL && parsed.INITIAL_ADMIN_PASSWORD
? {
initialAdmin: {
email: parsed.INITIAL_ADMIN_EMAIL,
displayName:
parsed.INITIAL_ADMIN_NAME ?? 'Administrator',
password: parsed.INITIAL_ADMIN_PASSWORD
}
}
: {})
}
}
+20
View File
@@ -0,0 +1,20 @@
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { loadEnvFile } from 'node:process'
export function loadEnvironmentFile(
environmentFile = process.env['ENV_FILE']
): string | undefined {
const configuredPath = environmentFile?.trim()
const filePath = resolve(configuredPath || '.env')
if (!existsSync(filePath)) {
if (configuredPath) {
throw new Error(`Environment file does not exist: ${filePath}`)
}
return undefined
}
loadEnvFile(filePath)
return filePath
}
+11
View File
@@ -2,14 +2,25 @@ import { existsSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { buildApp } from './app.js'
import { AuthService } from './auth-service.js'
import { loadConfig } from './config.js'
import { openDatabase } from './database.js'
import { loadEnvironmentFile } from './environment.js'
process.umask(0o077)
loadEnvironmentFile()
const config = loadConfig()
await mkdir(config.dataDirectory, { recursive: true, mode: 0o700 })
const database = openDatabase(join(config.dataDirectory, 'product-issue.sqlite'))
if (config.initialAdmin) {
const auth = new AuthService(
database,
config.appSecret,
config.sessionTtlMs
)
await auth.createInitialAdmin(config.initialAdmin)
}
const adminDirectory = resolve('dist/admin')
const serveAdmin =
config.nodeEnv === 'production' ||
+30
View File
@@ -20,6 +20,36 @@ afterEach(async () => {
})
describe('AuthService', () => {
it('creates the initial administrator only when the database is empty', async () => {
const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-'))
directories.push(directory)
const database = openDatabase(join(directory, 'database.sqlite'))
databases.push(database)
const auth = new AuthService(database, 'secret', 60 * 60 * 1_000)
await expect(
auth.createInitialAdmin({
email: 'first@example.com',
displayName: 'First admin',
password: 'first secure password'
})
).resolves.toBe(true)
await expect(
auth.createInitialAdmin({
email: 'second@example.com',
displayName: 'Second admin',
password: 'second secure password'
})
).resolves.toBe(false)
await expect(
auth.login('first@example.com', 'first secure password')
).resolves.toMatchObject({ displayName: 'First admin' })
await expect(
auth.login('second@example.com', 'second secure password')
).resolves.toBeUndefined()
})
it('invalidates existing sessions when an administrator is reset', async () => {
const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-'))
directories.push(directory)
+49 -4
View File
@@ -1,7 +1,17 @@
import { describe, expect, it } from 'vitest'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../src/server/config.js'
const secret = 'test-secret-that-is-at-least-thirty-two-characters'
const directories: string[] = []
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('loadConfig', () => {
it('normalizes a valid production origin', () => {
@@ -40,11 +50,46 @@ describe('loadConfig', () => {
).toThrow()
})
it('rejects the example placeholder secret', () => {
it('accepts an explicitly configured secret without a length restriction', () => {
expect(loadConfig({ APP_SECRET: 'short' }).appSecret).toBe('short')
})
it('generates and reuses a secret when APP_SECRET is omitted', () => {
const directory = mkdtempSync(join(tmpdir(), 'product-issue-config-'))
directories.push(directory)
const first = loadConfig({ DATA_DIRECTORY: directory })
const second = loadConfig({ DATA_DIRECTORY: directory })
expect(first.appSecret).toHaveLength(43)
expect(second.appSecret).toBe(first.appSecret)
expect(readFileSync(join(directory, '.app-secret'), 'utf8').trim()).toBe(
first.appSecret
)
})
it('parses initial administrator configuration', () => {
expect(
loadConfig({
APP_SECRET: secret,
INITIAL_ADMIN_EMAIL: 'admin@example.com',
INITIAL_ADMIN_PASSWORD: 'a local admin password'
}).initialAdmin
).toEqual({
email: 'admin@example.com',
displayName: 'Administrator',
password: 'a local admin password'
})
})
it('requires initial administrator email and password together', () => {
expect(() =>
loadConfig({
APP_SECRET: 'replace-with-at-least-32-random-characters'
APP_SECRET: secret,
INITIAL_ADMIN_EMAIL: 'admin@example.com'
})
).toThrow()
).toThrow(
'INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD must be configured together'
)
})
})
+59
View File
@@ -0,0 +1,59 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { loadEnvironmentFile } from '../src/server/environment.js'
const testVariable = 'PRODUCT_ISSUE_ENV_FILE_TEST'
const originalValue = process.env[testVariable]
const temporaryDirectories: string[] = []
afterEach(() => {
if (originalValue === undefined) {
delete process.env[testVariable]
} else {
process.env[testVariable] = originalValue
}
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function environmentFile(value: string): string {
const directory = mkdtempSync(join(tmpdir(), 'product-issue-env-'))
temporaryDirectories.push(directory)
const filePath = join(directory, '.env')
writeFileSync(filePath, `${testVariable}=${value}\n`, {
encoding: 'utf8',
mode: 0o600
})
return filePath
}
describe('loadEnvironmentFile', () => {
it('loads values from an explicit environment file', () => {
delete process.env[testVariable]
const filePath = environmentFile('from-file')
expect(loadEnvironmentFile(filePath)).toBe(filePath)
expect(process.env[testVariable]).toBe('from-file')
})
it('does not override values already provided by the process', () => {
process.env[testVariable] = 'from-process'
loadEnvironmentFile(environmentFile('from-file'))
expect(process.env[testVariable]).toBe('from-process')
})
it('fails when an explicitly configured file does not exist', () => {
const directory = mkdtempSync(join(tmpdir(), 'product-issue-env-'))
temporaryDirectories.push(directory)
expect(() => loadEnvironmentFile(join(directory, '.env'))).toThrow(
'Environment file does not exist'
)
})
})