@wdyy/skills 0.1.12 → 0.1.13
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.
- package/.well-known/skills/index.json +2 -2
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +30 -46
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +103 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +138 -155
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +97 -97
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +93 -116
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +185 -311
- package/.well-known/skills/wdyy-deployment-standard/templates/{Dockerfile.template → backend.Dockerfile.template} +5 -9
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +210 -256
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +11 -13
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -19
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +0 -6
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +9 -4
- package/README.md +2 -2
- package/lib/wdyy-cli.js +2 -1
- package/package.json +1 -1
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +0 -99
package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs
CHANGED
|
@@ -1,120 +1,120 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
1
2
|
import assert from 'node:assert/strict';
|
|
2
|
-
import {
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
5
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
7
9
|
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const script = join(dirname(fileURLToPath(import.meta.url)), 'generate-deployment-files.mjs');
|
|
12
|
+
const created = [];
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
const realEnv = `# 前端
|
|
15
|
+
FRONTEND_URL=0.0.0.0
|
|
16
|
+
FRONTEND_PORT=5173
|
|
17
|
+
# 后端
|
|
18
|
+
BACKEND_URL=0.0.0.0
|
|
19
|
+
BACKEND_PORT=3000
|
|
20
|
+
# 数据库
|
|
21
|
+
DB_URL=db.internal
|
|
22
|
+
DB_PORT=5432
|
|
23
|
+
DB_USER=app
|
|
24
|
+
DB_PASSWORD=secret
|
|
25
|
+
DB_NAME=app
|
|
26
|
+
DB_SCHEMA=public
|
|
27
|
+
# API
|
|
28
|
+
API_URL=
|
|
29
|
+
# 部署
|
|
30
|
+
`;
|
|
31
|
+
const exampleEnv = `# 前端
|
|
32
|
+
FRONTEND_URL=0.0.0.0
|
|
33
|
+
FRONTEND_PORT=5173
|
|
34
|
+
# 后端
|
|
35
|
+
BACKEND_URL=0.0.0.0
|
|
36
|
+
BACKEND_PORT=3000
|
|
37
|
+
# 数据库
|
|
38
|
+
DB_URL=
|
|
39
|
+
DB_PORT=5432
|
|
40
|
+
DB_USER=
|
|
41
|
+
DB_PASSWORD=
|
|
42
|
+
DB_NAME=
|
|
43
|
+
DB_SCHEMA=
|
|
44
|
+
# API
|
|
45
|
+
API_URL=
|
|
46
|
+
# 部署
|
|
47
|
+
`;
|
|
15
48
|
|
|
16
|
-
async function
|
|
17
|
-
const root = await mkdtemp(join(tmpdir(), '
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
await writeFile(join(root, '.env'), initializedEnvironment, { mode: 0o600 });
|
|
22
|
-
await writeFile(join(root, '.env.example'), initializedEnvironment);
|
|
49
|
+
async function fixture(env = realEnv, example = exampleEnv) {
|
|
50
|
+
const root = await mkdtemp(join(tmpdir(), 'wdyy-deploy-generator-'));
|
|
51
|
+
created.push(root);
|
|
52
|
+
await writeFile(join(root, '.env'), env, { mode: 0o600 });
|
|
53
|
+
await writeFile(join(root, '.env.example'), example, { mode: 0o644 });
|
|
23
54
|
return root;
|
|
24
55
|
}
|
|
25
56
|
|
|
26
|
-
function
|
|
27
|
-
return
|
|
57
|
+
async function invoke(root, ...args) {
|
|
58
|
+
return run(process.execPath, [script, '--target', root, ...args]);
|
|
28
59
|
}
|
|
29
60
|
|
|
30
|
-
test(
|
|
31
|
-
|
|
32
|
-
const generated = run(root, '--write');
|
|
33
|
-
assert.equal(generated.status, 0, generated.stderr);
|
|
34
|
-
const checked = run(root, '--check');
|
|
35
|
-
assert.equal(checked.status, 0, checked.stderr);
|
|
36
|
-
assert.match(checked.stdout, /verified deterministic deployment files/);
|
|
37
|
-
assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /server_menu/);
|
|
38
|
-
assert.match(await readFile(join(root, 'scripts/deployment/docker-compose.yml'), 'utf8'), /^ frontend:[\s\S]*^ backend:/m);
|
|
39
|
-
await assert.rejects(stat(join(root, 'scripts/deployment/docker-compose.blue-green.yml')));
|
|
40
|
-
const environment = await readFile(join(root, '.env'), 'utf8');
|
|
41
|
-
const example = await readFile(join(root, '.env.example'), 'utf8');
|
|
42
|
-
assert.match(environment, /^# 前端\nFRONTEND_URL=0\.0\.0\.0\nFRONTEND_PORT=5173$/m);
|
|
43
|
-
assert.match(environment, /^# 部署\nPROJECT_NAME=\nDOCKER_BIND_IP=/m);
|
|
44
|
-
assert.doesNotMatch(environment, /PROJECT_HTTP_PORT|BLUE_PORT|GREEN_PORT/);
|
|
45
|
-
assert.match(environment, /^FRONTEND_BASE_IMAGE=nginx:stable$/m);
|
|
46
|
-
assert.match(environment, /^BACKEND_BASE_IMAGE=node:24-alpine3\.24$/m);
|
|
47
|
-
assert.equal(example, environment);
|
|
48
|
-
if (process.platform !== 'win32') assert.equal((await stat(join(root, '.env'))).mode & 0o777, 0o600);
|
|
61
|
+
test.after(async () => {
|
|
62
|
+
await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
|
|
49
63
|
});
|
|
50
64
|
|
|
51
|
-
test('
|
|
52
|
-
const root = await
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
test('生成统一 Docker 交付文件并通过确定性检查', async () => {
|
|
66
|
+
const root = await fixture();
|
|
67
|
+
const result = await invoke(root, '--write');
|
|
68
|
+
assert.match(result.stdout, /generated unified Docker delivery files/);
|
|
69
|
+
for (const path of ['deploy.sh', '.dockerignore', 'src/frontend/Dockerfile', 'src/backend/Dockerfile', 'scripts/deployment/docker-compose.yml', 'scripts/deployment/frontend-container.conf.template']) {
|
|
70
|
+
assert.ok((await stat(join(root, path))).isFile(), path);
|
|
71
|
+
}
|
|
72
|
+
assert.equal((await stat(join(root, 'deploy.sh'))).mode & 0o777, 0o755);
|
|
73
|
+
assert.equal((await stat(join(root, '.env'))).mode & 0o777, 0o600);
|
|
74
|
+
assert.match(await readFile(join(root, '.env'), 'utf8'), /FRONTEND_BASE_IMAGE=nginx:stable/);
|
|
75
|
+
assert.match(await readFile(join(root, '.env.example'), 'utf8'), /BACKEND_BASE_IMAGE=node:24-alpine3\.24/);
|
|
76
|
+
const check = await invoke(root, '--check');
|
|
77
|
+
assert.match(check.stdout, /verified unified Docker delivery files/);
|
|
58
78
|
});
|
|
59
79
|
|
|
60
|
-
test('
|
|
61
|
-
const root = await
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
assert.
|
|
67
|
-
assert.match(generated.stderr, /section order/);
|
|
68
|
-
assert.equal(await readFile(envPath, 'utf8'), before);
|
|
80
|
+
test('扩充部署段时完整保留真实环境值', async () => {
|
|
81
|
+
const root = await fixture();
|
|
82
|
+
await invoke(root, '--write');
|
|
83
|
+
const actual = await readFile(join(root, '.env'), 'utf8');
|
|
84
|
+
assert.match(actual, /DB_URL=db\.internal/);
|
|
85
|
+
assert.match(actual, /DB_PASSWORD=secret/);
|
|
86
|
+
assert.match(actual, /PROJECT_NAME=\nDOCKER_BIND_IP=\n/);
|
|
69
87
|
});
|
|
70
88
|
|
|
71
|
-
test('
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
assert.notEqual(generated.status, 0);
|
|
79
|
-
assert.match(generated.stderr, /variable names must remain consistent/);
|
|
80
|
-
assert.equal(await readFile(join(root, '.env'), 'utf8'), envBefore);
|
|
81
|
-
assert.equal(await readFile(examplePath, 'utf8'), exampleBefore);
|
|
89
|
+
test('非标准环境文件被拒绝且不修改', async () => {
|
|
90
|
+
const invalid = realEnv.replace('# 数据库\n', '');
|
|
91
|
+
const root = await fixture(invalid);
|
|
92
|
+
const before = await readFile(join(root, '.env'));
|
|
93
|
+
await assert.rejects(invoke(root, '--write'), /section order|inside a standard section/);
|
|
94
|
+
assert.deepEqual(await readFile(join(root, '.env')), before);
|
|
95
|
+
await assert.rejects(stat(join(root, 'deploy.sh')), { code: 'ENOENT' });
|
|
82
96
|
});
|
|
83
97
|
|
|
84
|
-
test('
|
|
85
|
-
const root = await
|
|
86
|
-
assert.
|
|
87
|
-
await
|
|
88
|
-
await chmod(join(root, 'deploy.sh'), 0o755);
|
|
89
|
-
const refused = run(root, '--write');
|
|
90
|
-
assert.notEqual(refused.status, 0);
|
|
91
|
-
assert.match(refused.stderr, /Refusing to overwrite/);
|
|
92
|
-
assert.equal(await readFile(join(root, 'deploy.sh'), 'utf8'), '# changed\n');
|
|
93
|
-
const forced = run(root, '--write', '--force');
|
|
94
|
-
assert.equal(forced.status, 0, forced.stderr);
|
|
95
|
-
assert.equal(run(root, '--check').status, 0);
|
|
98
|
+
test('环境变量集合不一致时拒绝修改', async () => {
|
|
99
|
+
const root = await fixture(realEnv, exampleEnv.replace('API_URL=', 'API_URL=\nAPI_TOKEN='));
|
|
100
|
+
await assert.rejects(invoke(root, '--write'), /variable names must remain consistent/);
|
|
101
|
+
await assert.rejects(stat(join(root, 'deploy.sh')), { code: 'ENOENT' });
|
|
96
102
|
});
|
|
97
103
|
|
|
98
|
-
test('
|
|
99
|
-
const root = await
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
{ encoding: 'utf8' },
|
|
108
|
-
);
|
|
109
|
-
assert.notEqual(duplicateTarget.status, 0);
|
|
110
|
-
assert.match(duplicateTarget.stderr, /Duplicate or missing --target argument/);
|
|
104
|
+
test('差异生成文件默认拒绝覆盖,force 后恢复一致', async () => {
|
|
105
|
+
const root = await fixture();
|
|
106
|
+
await invoke(root, '--write');
|
|
107
|
+
await writeFile(join(root, 'deploy.sh'), '#!/bin/sh\nexit 9\n');
|
|
108
|
+
await chmod(join(root, 'deploy.sh'), 0o755);
|
|
109
|
+
await assert.rejects(invoke(root, '--write'), /Refusing to overwrite differing deployment files/);
|
|
110
|
+
assert.equal(await readFile(join(root, 'deploy.sh'), 'utf8'), '#!/bin/sh\nexit 9\n');
|
|
111
|
+
await invoke(root, '--write', '--force');
|
|
112
|
+
await invoke(root, '--check');
|
|
111
113
|
});
|
|
112
114
|
|
|
113
|
-
test('
|
|
114
|
-
const root = await
|
|
115
|
-
|
|
116
|
-
await
|
|
117
|
-
|
|
118
|
-
assert.notEqual(generated.status, 0);
|
|
119
|
-
assert.match(generated.stderr, /legacy duplicate configuration/);
|
|
115
|
+
test('非法参数和旧部署配置被明确拒绝', async () => {
|
|
116
|
+
const root = await fixture(realEnv.replace('# 部署\n', '# 部署\nRELEASE_VERSION=old\n'));
|
|
117
|
+
await assert.rejects(invoke(root, '--write'), /removed deployment configuration: RELEASE_VERSION/);
|
|
118
|
+
await assert.rejects(run(process.execPath, [script, '--target', root, '--write', '--check']), /Exactly one/);
|
|
119
|
+
await assert.rejects(run(process.execPath, [script, '--target', root, '--check', '--force']), /Usage/);
|
|
120
120
|
});
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
3
4
|
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
|
|
4
|
-
import { basename, join,
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
5
7
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const [deploy, compose] = await Promise.all([readFile(deployPath, 'utf8'), readFile(composePath, 'utf8')]);
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
const [packageArgument] = process.argv.slice(2);
|
|
10
|
+
if (!packageArgument || process.argv.length !== 3) throw new Error('Usage: validate-deployment-package.mjs <extracted-package-root>');
|
|
11
|
+
const packageRoot = resolve(packageArgument);
|
|
12
|
+
const expectedFiles = ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar', 'manifest.sha256'];
|
|
12
13
|
|
|
13
|
-
function
|
|
14
|
-
if (!
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function assertDeepEqual(actual, expected, message) {
|
|
18
|
-
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
14
|
+
function assert(condition, message) {
|
|
15
|
+
if (!condition) throw new Error(message);
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
function parseDotenv(text) {
|
|
@@ -26,6 +23,7 @@ function parseDotenv(text) {
|
|
|
26
23
|
if (!match) throw new Error(`Invalid root .env line ${index + 1}`);
|
|
27
24
|
if (values.has(match[1])) throw new Error(`Duplicate root .env key: ${match[1]}`);
|
|
28
25
|
let value = match[2];
|
|
26
|
+
if (value.includes('$(') || value.includes('`')) throw new Error(`Executable syntax is forbidden in root .env: ${match[1]}`);
|
|
29
27
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
|
30
28
|
else if (value.startsWith('"') || value.startsWith("'")) throw new Error(`Unclosed root .env quote: ${match[1]}`);
|
|
31
29
|
values.set(match[1], value);
|
|
@@ -33,120 +31,99 @@ function parseDotenv(text) {
|
|
|
33
31
|
return values;
|
|
34
32
|
}
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
[/'2\. 运行或替换后端'/, 'Missing backend menu item'],
|
|
41
|
-
[/'3\. 运行或替换前后端'/, 'Missing dual-service menu item'],
|
|
42
|
-
[/'4\. 停止'/, 'Missing stop menu item'],
|
|
43
|
-
[/docker save -o "\$version_root\/frontend-image\.tar"/, 'Missing frontend image export'],
|
|
44
|
-
[/docker save -o "\$version_root\/backend-image\.tar"/, 'Missing backend image export'],
|
|
45
|
-
[/frontend_image="\$\{PROJECT_NAME\}_frontend:\$version"/, 'Missing versioned frontend image name'],
|
|
46
|
-
[/backend_image="\$\{PROJECT_NAME\}_backend:\$version"/, 'Missing versioned backend image name'],
|
|
47
|
-
[/compose up -d --force-recreate --no-deps frontend/, 'Frontend operation must only recreate frontend'],
|
|
48
|
-
[/compose up -d --force-recreate --no-deps backend/, 'Backend operation must only recreate backend'],
|
|
49
|
-
[/compose up -d --force-recreate frontend backend/, 'Combined operation must recreate both services'],
|
|
50
|
-
[/compose stop frontend backend/, 'Stop operation must stop both services'],
|
|
51
|
-
[/logs\/deploy\.log/, 'Server operations must write deploy.log'],
|
|
52
|
-
[/pnpm test/, 'Missing test release gate'],
|
|
53
|
-
[/pnpm lint/, 'Missing lint release gate'],
|
|
54
|
-
[/pnpm typecheck/, 'Missing typecheck release gate'],
|
|
55
|
-
[/pnpm build/, 'Missing build release gate'],
|
|
56
|
-
]) requirePattern(deploy, pattern, message);
|
|
57
|
-
|
|
58
|
-
if (/^\s*(?:start|replace|restart|remove|status|rollback)\)/m.test(deploy)) throw new Error('Legacy server commands are forbidden');
|
|
59
|
-
if (/\bsource\s+["']?[^\n]*\.env/.test(deploy)) throw new Error('Environment files must be parsed as data, not sourced');
|
|
60
|
-
if (/\beval\b/.test(deploy)) throw new Error('deploy.sh must not use eval');
|
|
61
|
-
if (/\bpsql\b/.test(deploy)) throw new Error('deploy.sh must not execute database clients');
|
|
62
|
-
if (/\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy)) throw new Error('deploy.sh must not upload or pull artifacts');
|
|
63
|
-
if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${compose}`)) throw new Error('Fixed /srv deployment roots are forbidden');
|
|
64
|
-
if (/nginx\s+-(?:t|s)|active-upstreams|ACTIVE_COLOR|PREVIOUS_COLOR/.test(deploy)) throw new Error('Host Nginx and blue-green state are forbidden');
|
|
65
|
-
|
|
66
|
-
const serviceNames = [...compose.matchAll(/^ ([a-z-]+):$/gm)].map((match) => match[1]);
|
|
67
|
-
assertDeepEqual(serviceNames, ['frontend', 'backend'], 'Compose must define exactly two ordered services');
|
|
68
|
-
if (compose.includes(':-') || compose.includes('not-configured')) throw new Error('Compose image fallback is forbidden');
|
|
69
|
-
if ((compose.match(/image: \$\{FRONTEND_IMAGE:\?FRONTEND_IMAGE is required\}/g) ?? []).length !== 1) throw new Error('Frontend must require FRONTEND_IMAGE');
|
|
70
|
-
if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !== 1) throw new Error('Backend must require BACKEND_IMAGE');
|
|
71
|
-
if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !== 2) throw new Error('Both services must use root .env');
|
|
72
|
-
if ((compose.match(/^\s*- \.\.\/\.\.\/logs:\/app\/logs\s*$/gm) ?? []).length !== 2) throw new Error('Both services must mount flat project-root logs');
|
|
73
|
-
if (!compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"')) throw new Error('Frontend must directly expose FRONTEND_PORT');
|
|
74
|
-
if (!compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"')) throw new Error('Backend must directly expose BACKEND_PORT');
|
|
75
|
-
if (/LOG_DIR|HOST_LOG_DIR|logs\/(?:frontend|backend|blue|green)|container_name:/.test(compose)) throw new Error('Configurable, nested logs or container_name are forbidden');
|
|
76
|
-
if (/frontend-(?:blue|green)|backend-(?:blue|green)|PROJECT_HTTP_PORT|_(?:BLUE|GREEN)_PORT|nginx\/site/.test(compose)) throw new Error('Blue-green or host Nginx Compose configuration is forbidden');
|
|
77
|
-
|
|
78
|
-
const rootEnvPath = join(packageRoot, '.env');
|
|
79
|
-
const rootEnvStat = await stat(rootEnvPath);
|
|
80
|
-
if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0) throw new Error('Root .env must be a private owner-readable file');
|
|
81
|
-
const rootEnvText = await readFile(rootEnvPath, 'utf8');
|
|
82
|
-
const env = parseDotenv(rootEnvText);
|
|
83
|
-
const sectionNames = [...rootEnvText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
|
|
84
|
-
if (sectionNames.join('|') !== '前端|后端|数据库|API|部署') throw new Error('Root .env must use the five standard sections in order');
|
|
85
|
-
for (const key of ['FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
|
|
86
|
-
if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
|
|
34
|
+
async function requireRegular(path, label) {
|
|
35
|
+
const info = await lstat(path);
|
|
36
|
+
assert(info.isFile() && !info.isSymbolicLink(), `${label} must be a regular file`);
|
|
37
|
+
return info;
|
|
87
38
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
39
|
+
|
|
40
|
+
const rootInfo = await stat(packageRoot);
|
|
41
|
+
assert(rootInfo.isDirectory(), 'Package root must be a directory');
|
|
42
|
+
const entries = await readdir(packageRoot, { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.name === 'logs') {
|
|
45
|
+
assert(entry.isDirectory() && !entry.isSymbolicLink(), 'logs must be a regular directory');
|
|
46
|
+
} else {
|
|
47
|
+
assert(expectedFiles.includes(entry.name) && entry.isFile() && !entry.isSymbolicLink(), `Unexpected package entry: ${entry.name}`);
|
|
91
48
|
}
|
|
92
49
|
}
|
|
93
|
-
|
|
94
|
-
|
|
50
|
+
for (const name of expectedFiles) await requireRegular(join(packageRoot, name), name);
|
|
51
|
+
|
|
52
|
+
const envInfo = await requireRegular(join(packageRoot, '.env'), '.env');
|
|
53
|
+
assert((envInfo.mode & 0o077) === 0 && (envInfo.mode & 0o400) !== 0, 'Root .env must be owner-readable with no group or other permissions');
|
|
54
|
+
const envText = await readFile(join(packageRoot, '.env'), 'utf8');
|
|
55
|
+
const env = parseDotenv(envText);
|
|
56
|
+
const sections = [...envText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
|
|
57
|
+
assert(sections.join('|') === '前端|后端|数据库|API|部署', 'Root .env must use the five standard sections in order');
|
|
58
|
+
for (const key of ['FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
|
|
59
|
+
assert(env.get(key), `Root .env is missing ${key}`);
|
|
60
|
+
}
|
|
61
|
+
assert(/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME')), 'Invalid PROJECT_NAME');
|
|
95
62
|
for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT']) {
|
|
96
63
|
const port = Number(env.get(key));
|
|
97
|
-
|
|
64
|
+
assert(Number.isInteger(port) && port >= 1 && port <= 65535, `${key} must be a valid port`);
|
|
65
|
+
}
|
|
66
|
+
assert(env.get('FRONTEND_PORT') !== env.get('BACKEND_PORT'), 'Frontend and backend ports must differ');
|
|
67
|
+
for (const key of env.keys()) {
|
|
68
|
+
assert(!['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT', 'SERVER_PROJECTS_ROOT', 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR', 'FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE', 'MIGRATION_RUNNER_SOURCE', 'RELEASE_VERSION'].includes(key) && !/_(?:HEALTH|VERSION)_URL$/.test(key), `Root .env contains removed configuration: ${key}`);
|
|
98
69
|
}
|
|
99
|
-
if (env.get('FRONTEND_PORT') === env.get('BACKEND_PORT')) throw new Error('Frontend and backend host ports must differ');
|
|
100
70
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
71
|
+
const deploy = await readFile(join(packageRoot, 'deploy.sh'), 'utf8');
|
|
72
|
+
const compose = await readFile(join(packageRoot, 'docker-compose.yml'), 'utf8');
|
|
73
|
+
for (const [pattern, message] of [
|
|
74
|
+
[/if \[\[ "\$#" -eq 0 \]\]; then server_deploy/, 'No-argument execution must deploy the complete stack'],
|
|
75
|
+
[/"\$1" == build/, 'Missing local build command'],
|
|
76
|
+
[/"\$1" == stop/, 'Missing stop command'],
|
|
77
|
+
[/"\$1" == status/, 'Missing status command'],
|
|
78
|
+
[/docker image inspect "\$FRONTEND_BASE_IMAGE"/, 'Frontend local base image must be checked'],
|
|
79
|
+
[/docker image inspect "\$BACKEND_BASE_IMAGE"/, 'Backend local base image must be checked'],
|
|
80
|
+
[/docker build --pull=false/g, 'Docker builds must disable pull'],
|
|
81
|
+
[/docker save -o "\$package_root\/frontend-image\.tar"/, 'Frontend image must be exported separately'],
|
|
82
|
+
[/docker save -o "\$package_root\/backend-image\.tar"/, 'Backend image must be exported separately'],
|
|
83
|
+
[/compose up -d --force-recreate --no-build --pull never --wait frontend backend/, 'Deploy must recreate and wait for both services without build or pull'],
|
|
84
|
+
[/compose stop frontend backend/, 'Stop must target both services'],
|
|
85
|
+
[/compose ps frontend backend/, 'Status must inspect both services'],
|
|
86
|
+
]) assert(pattern.test(deploy), message);
|
|
87
|
+
assert((deploy.match(/docker build --pull=false/g) ?? []).length === 2, 'Exactly two Docker builds must disable pull');
|
|
88
|
+
assert(!/\bdocker\s+pull\b/.test(deploy), 'docker pull is forbidden');
|
|
89
|
+
assert(!/\bsource\s+[^\n]*\.env|\beval\b/.test(deploy), 'Environment files must be parsed as data');
|
|
90
|
+
assert(!/server_menu|运行或替换|read_release_pointer|release_ledger|AGENTS\.md/.test(deploy), 'Legacy menu or release ledger logic is forbidden');
|
|
91
|
+
assert(!/\bpsql\b|\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy), 'Database, upload or source-pull commands are forbidden');
|
|
92
|
+
assert(!/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${compose}`), 'Fixed server roots are forbidden');
|
|
108
93
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
94
|
+
const serviceNames = [...compose.matchAll(/^ ([a-z][a-z0-9_-]*):$/gm)].map((match) => match[1]);
|
|
95
|
+
assert(JSON.stringify(serviceNames) === JSON.stringify(['frontend', 'backend']), `Compose must define only frontend and backend; got ${serviceNames.join(', ')}`);
|
|
96
|
+
assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend:latest"'), 'Compose frontend image is not fixed');
|
|
97
|
+
assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_backend:latest"'), 'Compose backend image is not fixed');
|
|
98
|
+
assert((compose.match(/env_file: \.\/\.env/g) ?? []).length === 2, 'Both services must use root .env');
|
|
99
|
+
assert((compose.match(/^\s*- \.\/logs:\/app\/logs\s*$/gm) ?? []).length === 2, 'Both services must mount root logs');
|
|
100
|
+
assert(compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"'), 'Frontend port mapping is invalid');
|
|
101
|
+
assert(compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"'), 'Backend port mapping is invalid');
|
|
102
|
+
assert(!/container_name:|\b(?:postgres|database|db):\s*$|postgres:\d|DATABASE_MIGRATION|MIGRATIONS_SOURCE|RELEASE_VERSION|blue|green/i.test(compose), 'Compose contains a database, migration, release or blue-green configuration');
|
|
116
103
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
for (const line of manifest.trimEnd().split('\n')) {
|
|
125
|
-
const match = line.match(/^([0-9a-f]{64}) ([^/].*)$/);
|
|
126
|
-
if (!match || match[2].split('/').includes('..')) throw new Error(`Invalid manifest entry: ${line}`);
|
|
127
|
-
if (entries.has(match[2])) throw new Error(`Duplicate manifest entry: ${match[2]}`);
|
|
128
|
-
entries.set(match[2], match[1]);
|
|
104
|
+
const manifestText = await readFile(join(packageRoot, 'manifest.sha256'), 'utf8');
|
|
105
|
+
const manifestEntries = new Map();
|
|
106
|
+
for (const line of manifestText.trimEnd().split('\n')) {
|
|
107
|
+
const match = line.match(/^([0-9a-f]{64}) ([^/]+)$/);
|
|
108
|
+
assert(match, `Invalid manifest entry: ${line}`);
|
|
109
|
+
assert(!manifestEntries.has(match[2]), `Duplicate manifest entry: ${match[2]}`);
|
|
110
|
+
manifestEntries.set(match[2], match[1]);
|
|
129
111
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
112
|
+
const expectedManifestFiles = expectedFiles.filter((name) => name !== 'manifest.sha256').sort();
|
|
113
|
+
assert(JSON.stringify([...manifestEntries.keys()].sort()) === JSON.stringify(expectedManifestFiles), 'Manifest coverage is incomplete');
|
|
114
|
+
for (const [name, expectedHash] of manifestEntries) {
|
|
115
|
+
const actualHash = createHash('sha256').update(await readFile(join(packageRoot, name))).digest('hex');
|
|
116
|
+
assert(actualHash === expectedHash, `Checksum mismatch: ${name}`);
|
|
134
117
|
}
|
|
135
118
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
119
|
+
async function assertImageArchive(name, expectedTag) {
|
|
120
|
+
const archive = join(packageRoot, name);
|
|
121
|
+
const { stdout } = await run('tar', ['-xOf', archive, 'manifest.json'], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 });
|
|
122
|
+
const parsed = JSON.parse(stdout);
|
|
123
|
+
assert(Array.isArray(parsed) && parsed.length === 1, `${name} must contain exactly one image manifest`);
|
|
124
|
+
assert(JSON.stringify(parsed[0].RepoTags) === JSON.stringify([expectedTag]), `${name} must contain only ${expectedTag}`);
|
|
141
125
|
}
|
|
126
|
+
await assertImageArchive('frontend-image.tar', `${env.get('PROJECT_NAME')}_frontend:latest`);
|
|
127
|
+
await assertImageArchive('backend-image.tar', `${env.get('PROJECT_NAME')}_backend:latest`);
|
|
142
128
|
|
|
143
|
-
|
|
144
|
-
const result = [];
|
|
145
|
-
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
146
|
-
const path = join(directory, entry.name);
|
|
147
|
-
if (entry.isSymbolicLink()) throw new Error(`Release package must not contain symbolic links: ${relative(root, path)}`);
|
|
148
|
-
if (entry.isDirectory()) result.push(...await listFiles(root, path));
|
|
149
|
-
else if (entry.isFile()) result.push(relative(root, path).split(sep).join('/'));
|
|
150
|
-
}
|
|
151
|
-
return result;
|
|
152
|
-
}
|
|
129
|
+
process.stdout.write(`valid unified Docker delivery package for ${env.get('PROJECT_NAME')}\n`);
|