fix(deploy): .env 中带空格的值未加引号导致 install.sh 以 127 失败

现象:
  ./deploy/.env: line 53: Miku.pmx: command not found
  [install] 第 53 行执行失败(退出码 127)

根因:deploy/.env.example 的 MODEL_CONVERT_PMX=Hatsune Miku.pmx 值含空格
却没加引号,install.sh 用 `set -a; . deploy/.env` 载入时被切成
「赋值 + 命令」,Miku.pmx 被当成命令执行。

修复:
1. 模板改为 MODEL_CONVERT_PMX="Hatsune Miku.pmx"(占位符值同样加引号);
2. 新增 deploy/env-lib.sh:.env 语法预检,install.sh / ctl.sh 在 source
   之前检查,报错给出「哪一行 / 原文 / 改法」, 与反引号建议单引号;
3. install.sh 的 ERR trap 补上出错文件名(lineno 属于被 source 的 .env,
   只报行号无法定位);
4. 新增 test/env-file.spec.ts 12 项回归测试,锁住「模板可被 shell 原样载入」;
5. 版本升到 0.2.1,README(.md/.txt)/DSH.md 补 .env 写法约定、FAQ 与测试数量。
This commit is contained in:
opendance
2026-09-15 22:26:58 +08:00
parent d6376ec5a5
commit ea58520fc2
14 changed files with 412 additions and 33 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "opendance-admin",
"private": true,
"version": "0.2.0",
"version": "0.2.1",
"type": "module",
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opendance-web-server",
"version": "0.2.0",
"version": "0.2.1",
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
"license": "MIT",
"private": true,
+189
View File
@@ -0,0 +1,189 @@
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
/**
* deploy/.env 的「可被 shell 安全载入」保证。
*
* 背景(真实故障):install.sh 用 `set -a; . deploy/.env` 载入配置,而模板里
* MODEL_CONVERT_PMX=Hatsune Miku.pmx
* 的值含空格却没加引号,这一行被切成「赋值 + 命令」,安装直接失败:
* ./deploy/.env: line 53: Miku.pmx: command not found
* [install] 第 53 行执行失败(退出码 127)
*
* 因此这里锁两条线:
* 1. deploy/.env.example 必须能被 `set -a; .` 原样载入(回归用例);
* 2. deploy/env-lib.sh 的预检能在载入前拦住不安全写法,并给出行号与改法。
*/
function findDeployFile(name: string): string {
const candidates = [
resolve(process.cwd(), '..', 'deploy', name), // 从 web-server/ 运行(vitest 默认)
resolve(process.cwd(), 'deploy', name), // 从仓库根运行
resolve(process.cwd(), '..', '..', 'deploy', name),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
throw new Error(`找不到 deploy/${name},候选路径:\n${candidates.join('\n')}`);
}
const EXAMPLE = findDeployFile('.env.example');
const ENV_LIB = findDeployFile('env-lib.sh');
const INSTALL_SH = findDeployFile('install.sh');
const CTL_SH = findDeployFile('ctl.sh');
const dirs: string[] = [];
function makeDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'opendance-env-'));
dirs.push(dir);
return dir;
}
afterEach(() => {
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true });
});
/** 用真实 shell 载入一个 .env(与 install.sh 的做法一致) */
function sourceEnv(file: string) {
return spawnSync(
'bash',
[
'-c',
'set -a; . "$1"; set +a; printf "%s\\n" "${MODEL_CONVERT_PMX:-}" "${DB_HOST:-}" "${DB_PASS:-}" "${UPLOAD_MAX_BYTES:-}"',
'_',
file,
],
{ encoding: 'utf8', timeout: 15000 },
);
}
/** 调用 deploy/env-lib.sh 的预检函数 */
function validate(file: string) {
return spawnSync('bash', ['-c', '. "$1"; opendance_validate_env_file "$2"', '_', ENV_LIB, file], {
encoding: 'utf8',
timeout: 15000,
});
}
function writeEnv(content: string): string {
const file = join(makeDir(), '.env');
writeFileSync(file, content);
return file;
}
describe('deploy/.env.example:必须能被 shell 原样载入', () => {
it('`set -a; . .env.example` 成功, 且带空格的值读回完整 (回归: 修复前此处退出码 127)', () => {
const result = sourceEnv(EXAMPLE);
expect(result.stderr).not.toContain('command not found');
expect(result.status).toBe(0);
const [pmx, host, pass, uploadMax] = result.stdout.trimEnd().split('\n');
expect(pmx).toBe('Hatsune Miku.pmx');
expect(host).toBe('<existing-mysql-host>');
expect(pass).toBe('<数据库口令>');
expect(uploadMax).toBe('536870912');
});
it('模板整体通过语法预检', () => {
const result = validate(EXAMPLE);
expect(result.status).toBe(0);
expect(result.stderr).toBe('');
});
it('模板里每个赋值行都通过预检(逐行定位,便于以后加字段时发现问题)', () => {
const lines = readFileSync(EXAMPLE, 'utf8').split('\n');
const assigns = lines.filter((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l));
expect(assigns.length).toBeGreaterThan(10); // 模板字段数量下限,防止误删
for (const line of assigns) {
expect(validate(writeEnv(`${line}\n`)).status, `模板行应通过预检:${line}`).toBe(0);
}
});
});
describe('deploy/env-lib.sh:载入前的语法预检', () => {
it('拦住「值含空格却未加引号」,并给出行号、原文与改法(本次故障的原始写法)', () => {
const result = validate(writeEnv('NODE_ENV=production\nMODEL_CONVERT_PMX=Hatsune Miku.pmx\n'));
expect(result.status).toBe(1);
expect(result.stderr).toContain('第 2 行');
expect(result.stderr).toContain('MODEL_CONVERT_PMX=Hatsune Miku.pmx');
expect(result.stderr).toContain('MODEL_CONVERT_PMX="Hatsune Miku.pmx"');
});
it('拦住 shell 元字符,且对 $( ) 建议单引号(双引号仍会执行)', () => {
const result = validate(writeEnv('A=x&y\nB=$(id)\n'));
expect(result.status).toBe(1);
expect(result.stderr).toContain('第 1 行');
expect(result.stderr).toContain('第 2 行');
expect(result.stderr).toContain("B='$(id)'");
});
it('拦住未闭合引号', () => {
const result = validate(writeEnv('A="unclosed\n'));
expect(result.status).toBe(1);
expect(result.stderr).toContain('双引号未闭合');
});
it('不误伤合法写法:* % 口令、IPv4、路径、空值、单引号、引号 + 行尾注释', () => {
const result = validate(
writeEnv(
[
'DB_PASS=S3cr3t*Pwd%1',
'DB_HOST=127.0.0.1',
'DB_PORT=3306',
'STORAGE_DIR=/var/lib/opendance/storage',
'EMPTY=',
"WITH_SPACE='a b c'",
'QUOTED_COMMENT="x y" # 行尾注释',
'MODEL_CONVERT_PMX="Hatsune Miku.pmx"',
'# 注释与空行也必须放过',
'',
].join('\n'),
),
);
expect(result.stderr).toBe('');
expect(result.status).toBe(0);
});
it('拦住非「键=值」的行号并指出原因', () => {
const result = validate(writeEnv('A=1\n这一行不是赋值\n'));
expect(result.status).toBe(1);
expect(result.stderr).toContain('第 2 行');
expect(result.stderr).toContain('不是「键=值」形式');
});
it('文件不存在时返回 1(而不是静默通过)', () => {
const result = validate(join(makeDir(), 'missing.env'));
expect(result.status).toBe(1);
expect(result.stderr).toContain('找不到配置文件');
});
});
describe('载入顺序:install.sh / ctl.sh 必须先预检再 source', () => {
it('install.sh 在 `. ./deploy/.env` 之前调用预检', () => {
const text = readFileSync(INSTALL_SH, 'utf8');
const check = text.indexOf('opendance_validate_env_file "./deploy/.env"');
const source = text.indexOf('. ./deploy/.env');
expect(check).toBeGreaterThan(-1);
expect(source).toBeGreaterThan(-1);
expect(check).toBeLessThan(source);
// .env 被 source 时 LINENO 指向 .env 的行号,报错必须带上文件名才定位得到
expect(text).toContain('${BASH_SOURCE[0]}');
});
it('install.sh 在迁移前再次预检安装目录里的 .env', () => {
const text = readFileSync(INSTALL_SH, 'utf8');
expect(text).toContain('opendance_validate_env_file "$APP_DIR/deploy/.env"');
});
it('ctl.sh 载入 .env 前调用预检,且引用同目录的 env-lib.sh', () => {
const text = readFileSync(CTL_SH, 'utf8');
expect(text).toContain('"$SCRIPT_DIR/env-lib.sh"');
const check = text.indexOf('opendance_validate_env_file "$APP_DIR/deploy/.env"');
const source = text.indexOf('. "$APP_DIR/deploy/.env"');
expect(check).toBeGreaterThan(-1);
expect(source).toBeGreaterThan(-1);
expect(check).toBeLessThan(source);
});
});