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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user