@wdyy/skills 0.1.12 → 0.1.14
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 +37 -47
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +110 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +161 -155
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +133 -95
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +109 -116
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +309 -298
- package/.well-known/skills/wdyy-deployment-standard/templates/backend.Dockerfile.template +17 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +231 -252
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +18 -13
- package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +3 -19
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +7 -4
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +12 -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/templates/Dockerfile.template +0 -22
package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs
CHANGED
|
@@ -1,120 +1,158 @@
|
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
assert.match(
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
test.after(async () => {
|
|
62
|
+
await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
|
|
63
|
+
});
|
|
64
|
+
|
|
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);
|
|
40
74
|
const environment = await readFile(join(root, '.env'), 'utf8');
|
|
41
|
-
|
|
42
|
-
assert.match(environment,
|
|
43
|
-
assert.match(environment,
|
|
44
|
-
assert.
|
|
45
|
-
|
|
46
|
-
assert.match(
|
|
47
|
-
assert.
|
|
48
|
-
|
|
75
|
+
assert.match(environment, /API_URL=\nAPI_PREFIX=api\nVITE_API_BASE_URL=\/api\n\n# 部署/);
|
|
76
|
+
assert.match(environment, /FRONTEND_BASE_IMAGE=nginx:stable/);
|
|
77
|
+
assert.match(environment, /DOCKER_PLATFORM=linux\/amd64/);
|
|
78
|
+
assert.match(await readFile(join(root, '.env.example'), 'utf8'), /BACKEND_BASE_IMAGE=node:24-alpine3\.24/);
|
|
79
|
+
const nginx = await readFile(join(root, 'scripts/deployment/frontend-container.conf.template'), 'utf8');
|
|
80
|
+
assert.match(nginx, /location \$\{VITE_API_BASE_URL\}\/ \{/);
|
|
81
|
+
assert.match(nginx, /proxy_pass http:\/\/backend:\$\{BACKEND_PORT\};/);
|
|
82
|
+
const frontendDockerfile = await readFile(join(root, 'src/frontend/Dockerfile'), 'utf8');
|
|
83
|
+
assert.match(frontendDockerfile, /ARG VITE_API_BASE_URL/);
|
|
84
|
+
assert.doesNotMatch(frontendDockerfile, /COPY\s+\.env/);
|
|
85
|
+
const backendDockerfile = await readFile(join(root, 'src/backend/Dockerfile'), 'utf8');
|
|
86
|
+
assert.match(backendDockerfile, /pnpm --filter \.\/src\/backend deploy --prod --legacy \/runtime/);
|
|
87
|
+
assert.match(backendDockerfile, /WORKDIR \/app\n/);
|
|
88
|
+
assert.match(backendDockerfile, /COPY --from=build \/runtime \.\/src\/backend\//);
|
|
89
|
+
assert.match(backendDockerfile, /CMD \["node", "src\/backend\/dist\/main\.js"\]/);
|
|
90
|
+
const check = await invoke(root, '--check');
|
|
91
|
+
assert.match(check.stdout, /verified unified Docker delivery files/);
|
|
92
|
+
assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /env COPYFILE_DISABLE=1 tar --no-xattrs -czf "\$candidate"/);
|
|
49
93
|
});
|
|
50
94
|
|
|
51
|
-
test('
|
|
52
|
-
const root = await
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
assert.
|
|
57
|
-
assert.match(
|
|
95
|
+
test('扩充部署段时完整保留真实环境值', async () => {
|
|
96
|
+
const root = await fixture();
|
|
97
|
+
await invoke(root, '--write');
|
|
98
|
+
const actual = await readFile(join(root, '.env'), 'utf8');
|
|
99
|
+
assert.match(actual, /DB_URL=db\.internal/);
|
|
100
|
+
assert.match(actual, /DB_PASSWORD=secret/);
|
|
101
|
+
assert.match(actual, /PROJECT_NAME=\nDOCKER_BIND_IP=\n/);
|
|
58
102
|
});
|
|
59
103
|
|
|
60
|
-
test('
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
assert.match(generated.stderr, /section order/);
|
|
68
|
-
assert.equal(await readFile(envPath, 'utf8'), before);
|
|
104
|
+
test('保留有效的自定义 API 路径', async () => {
|
|
105
|
+
const configuredReal = realEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=service\nVITE_API_BASE_URL=/service\n');
|
|
106
|
+
const configuredExample = exampleEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=service\nVITE_API_BASE_URL=/service\n');
|
|
107
|
+
const root = await fixture(configuredReal, configuredExample);
|
|
108
|
+
await invoke(root, '--write');
|
|
109
|
+
assert.match(await readFile(join(root, '.env'), 'utf8'), /API_PREFIX=service\nVITE_API_BASE_URL=\/service/);
|
|
110
|
+
await invoke(root, '--check');
|
|
69
111
|
});
|
|
70
112
|
|
|
71
|
-
test('
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
await
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
assert.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
113
|
+
test('缺失、非法或冲突的 API 路径被明确拒绝', async () => {
|
|
114
|
+
const partialReal = realEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api\n');
|
|
115
|
+
const partialExample = exampleEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api\n');
|
|
116
|
+
await assert.rejects(invoke(await fixture(partialReal, partialExample), '--write'), /must contain both/);
|
|
117
|
+
|
|
118
|
+
const invalidReal = realEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api/path\nVITE_API_BASE_URL=/api\/path\n');
|
|
119
|
+
const invalidExample = exampleEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api/path\nVITE_API_BASE_URL=/api\/path\n');
|
|
120
|
+
await assert.rejects(invoke(await fixture(invalidReal, invalidExample), '--write'), /one safe path segment/);
|
|
121
|
+
|
|
122
|
+
const conflictReal = realEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api\nVITE_API_BASE_URL=/service\n');
|
|
123
|
+
const conflictExample = exampleEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=api\nVITE_API_BASE_URL=/service\n');
|
|
124
|
+
await assert.rejects(invoke(await fixture(conflictReal, conflictExample), '--write'), /VITE_API_BASE_URL must equal \/api/);
|
|
82
125
|
});
|
|
83
126
|
|
|
84
|
-
test('
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
await
|
|
88
|
-
await
|
|
89
|
-
|
|
90
|
-
assert.
|
|
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);
|
|
127
|
+
test('非标准环境文件被拒绝且不修改', async () => {
|
|
128
|
+
const invalid = realEnv.replace('# 数据库\n', '');
|
|
129
|
+
const root = await fixture(invalid);
|
|
130
|
+
const before = await readFile(join(root, '.env'));
|
|
131
|
+
await assert.rejects(invoke(root, '--write'), /section order|inside a standard section/);
|
|
132
|
+
assert.deepEqual(await readFile(join(root, '.env')), before);
|
|
133
|
+
await assert.rejects(stat(join(root, 'deploy.sh')), { code: 'ENOENT' });
|
|
96
134
|
});
|
|
97
135
|
|
|
98
|
-
test('
|
|
99
|
-
const root = await
|
|
100
|
-
|
|
101
|
-
assert.
|
|
102
|
-
|
|
136
|
+
test('环境变量集合不一致时拒绝修改', async () => {
|
|
137
|
+
const root = await fixture(realEnv, exampleEnv.replace('API_URL=', 'API_URL=\nAPI_TOKEN='));
|
|
138
|
+
await assert.rejects(invoke(root, '--write'), /variable names must remain consistent/);
|
|
139
|
+
await assert.rejects(stat(join(root, 'deploy.sh')), { code: 'ENOENT' });
|
|
140
|
+
});
|
|
103
141
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
);
|
|
109
|
-
assert.
|
|
110
|
-
assert.
|
|
142
|
+
test('差异生成文件默认拒绝覆盖,force 后恢复一致', async () => {
|
|
143
|
+
const root = await fixture();
|
|
144
|
+
await invoke(root, '--write');
|
|
145
|
+
await writeFile(join(root, 'deploy.sh'), '#!/bin/sh\nexit 9\n');
|
|
146
|
+
await chmod(join(root, 'deploy.sh'), 0o755);
|
|
147
|
+
await assert.rejects(invoke(root, '--write'), /Refusing to overwrite differing deployment files/);
|
|
148
|
+
assert.equal(await readFile(join(root, 'deploy.sh'), 'utf8'), '#!/bin/sh\nexit 9\n');
|
|
149
|
+
await invoke(root, '--write', '--force');
|
|
150
|
+
await invoke(root, '--check');
|
|
111
151
|
});
|
|
112
152
|
|
|
113
|
-
test('
|
|
114
|
-
const root = await
|
|
115
|
-
|
|
116
|
-
await
|
|
117
|
-
|
|
118
|
-
assert.notEqual(generated.status, 0);
|
|
119
|
-
assert.match(generated.stderr, /legacy duplicate configuration/);
|
|
153
|
+
test('非法参数和旧部署配置被明确拒绝', async () => {
|
|
154
|
+
const root = await fixture(realEnv.replace('# 部署\n', '# 部署\nRELEASE_VERSION=old\n'));
|
|
155
|
+
await assert.rejects(invoke(root, '--write'), /removed deployment configuration: RELEASE_VERSION/);
|
|
156
|
+
await assert.rejects(run(process.execPath, [script, '--target', root, '--write', '--check']), /Exactly one/);
|
|
157
|
+
await assert.rejects(run(process.execPath, [script, '--target', root, '--check', '--force']), /Usage/);
|
|
120
158
|
});
|
|
@@ -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,115 @@ 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', 'API_PREFIX', 'VITE_API_BASE_URL', 'DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'DOCKER_PLATFORM', '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
|
+
assert(/^[A-Za-z][A-Za-z0-9_-]*$/.test(env.get('API_PREFIX')), 'API_PREFIX must be one safe path segment');
|
|
68
|
+
assert(env.get('VITE_API_BASE_URL') === `/${env.get('API_PREFIX')}`, 'VITE_API_BASE_URL must equal /API_PREFIX');
|
|
69
|
+
assert(/^(?:linux\/amd64|linux\/arm64)$/.test(env.get('DOCKER_PLATFORM')), 'Invalid DOCKER_PLATFORM');
|
|
70
|
+
for (const key of env.keys()) {
|
|
71
|
+
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
72
|
}
|
|
99
|
-
if (env.get('FRONTEND_PORT') === env.get('BACKEND_PORT')) throw new Error('Frontend and backend host ports must differ');
|
|
100
73
|
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
74
|
+
const deploy = await readFile(join(packageRoot, 'deploy.sh'), 'utf8');
|
|
75
|
+
const compose = await readFile(join(packageRoot, 'docker-compose.yml'), 'utf8');
|
|
76
|
+
for (const [pattern, message] of [
|
|
77
|
+
[/if \[\[ "\$#" -eq 0 \]\]; then server_deploy/, 'No-argument execution must deploy the complete stack'],
|
|
78
|
+
[/"\$1" == build/, 'Missing local build command'],
|
|
79
|
+
[/"\$1" == stop/, 'Missing stop command'],
|
|
80
|
+
[/"\$1" == status/, 'Missing status command'],
|
|
81
|
+
[/docker image inspect "\$FRONTEND_BASE_IMAGE"/, 'Frontend local base image must be checked'],
|
|
82
|
+
[/docker image inspect "\$BACKEND_BASE_IMAGE"/, 'Backend local base image must be checked'],
|
|
83
|
+
[/docker build --pull=false --platform "\$DOCKER_PLATFORM"/g, 'Docker builds must use the configured target platform and disable pull'],
|
|
84
|
+
[/docker save -o "\$package_root\/frontend-image\.tar"/, 'Frontend image must be exported separately'],
|
|
85
|
+
[/docker save -o "\$package_root\/backend-image\.tar"/, 'Backend image must be exported separately'],
|
|
86
|
+
[/env COPYFILE_DISABLE=1 tar --no-xattrs -czf "\$candidate"/, 'Delivery archive must exclude macOS metadata and extended attributes'],
|
|
87
|
+
[/docker version --format '\{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}'/, 'Deploy must read the Docker Server platform'],
|
|
88
|
+
[/compose up -d --force-recreate --no-build --pull never --wait frontend backend/, 'Deploy must recreate and wait for both services without build or pull'],
|
|
89
|
+
[/compose stop frontend backend/, 'Stop must target both services'],
|
|
90
|
+
[/compose ps frontend backend/, 'Status must inspect both services'],
|
|
91
|
+
]) assert(pattern.test(deploy), message);
|
|
92
|
+
assert((deploy.match(/docker build --pull=false --platform "\$DOCKER_PLATFORM"/g) ?? []).length === 2, 'Exactly two Docker builds must use the configured target platform and disable pull');
|
|
93
|
+
const deployStackIndex = deploy.indexOf('deploy_stack() {');
|
|
94
|
+
const serverPlatformCheckIndex = deploy.indexOf(' assert_server_platform\n', deployStackIndex);
|
|
95
|
+
const firstImageLoadIndex = deploy.indexOf(' docker load -i ', deployStackIndex);
|
|
96
|
+
assert(deployStackIndex >= 0 && serverPlatformCheckIndex > deployStackIndex && firstImageLoadIndex > serverPlatformCheckIndex, 'Docker Server platform must be checked before loading images');
|
|
97
|
+
assert(!/\bdocker\s+pull\b/.test(deploy), 'docker pull is forbidden');
|
|
98
|
+
assert(!/\bsource\s+[^\n]*\.env|\beval\b/.test(deploy), 'Environment files must be parsed as data');
|
|
99
|
+
assert(!/server_menu|运行或替换|read_release_pointer|release_ledger|AGENTS\.md/.test(deploy), 'Legacy menu or release ledger logic is forbidden');
|
|
100
|
+
assert(!/\bpsql\b|\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy), 'Database, upload or source-pull commands are forbidden');
|
|
101
|
+
assert(!/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${compose}`), 'Fixed server roots are forbidden');
|
|
108
102
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
103
|
+
const serviceNames = [...compose.matchAll(/^ ([a-z][a-z0-9_-]*):$/gm)].map((match) => match[1]);
|
|
104
|
+
assert(JSON.stringify(serviceNames) === JSON.stringify(['frontend', 'backend']), `Compose must define only frontend and backend; got ${serviceNames.join(', ')}`);
|
|
105
|
+
assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend:latest"'), 'Compose frontend image is not fixed');
|
|
106
|
+
assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_backend:latest"'), 'Compose backend image is not fixed');
|
|
107
|
+
assert((compose.match(/platform: "\$\{DOCKER_PLATFORM:\?DOCKER_PLATFORM is required\}"/g) ?? []).length === 2, 'Both services must use DOCKER_PLATFORM');
|
|
108
|
+
assert((compose.match(/env_file: \.\/\.env/g) ?? []).length === 2, 'Both services must use root .env');
|
|
109
|
+
assert((compose.match(/^\s*- \.\/logs:\/app\/logs\s*$/gm) ?? []).length === 2, 'Both services must mount root logs');
|
|
110
|
+
assert(compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"'), 'Frontend port mapping is invalid');
|
|
111
|
+
assert(compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"'), 'Backend port mapping is invalid');
|
|
112
|
+
assert(compose.includes('VITE_API_BASE_URL: "${VITE_API_BASE_URL:?VITE_API_BASE_URL is required}"'), 'Frontend API path is not passed to Nginx');
|
|
113
|
+
assert(compose.includes('BACKEND_PORT: "${BACKEND_PORT:?BACKEND_PORT is required}"'), 'Backend port is not passed to Nginx');
|
|
114
|
+
assert(compose.includes('NGINX_ENVSUBST_FILTER: "^(FRONTEND_URL|FRONTEND_PORT|VITE_API_BASE_URL|BACKEND_PORT)$"'), 'Nginx envsubst filter does not cover the runtime API proxy');
|
|
115
|
+
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
116
|
|
|
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]);
|
|
117
|
+
const manifestText = await readFile(join(packageRoot, 'manifest.sha256'), 'utf8');
|
|
118
|
+
const manifestEntries = new Map();
|
|
119
|
+
for (const line of manifestText.trimEnd().split('\n')) {
|
|
120
|
+
const match = line.match(/^([0-9a-f]{64}) ([^/]+)$/);
|
|
121
|
+
assert(match, `Invalid manifest entry: ${line}`);
|
|
122
|
+
assert(!manifestEntries.has(match[2]), `Duplicate manifest entry: ${match[2]}`);
|
|
123
|
+
manifestEntries.set(match[2], match[1]);
|
|
129
124
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
125
|
+
const expectedManifestFiles = expectedFiles.filter((name) => name !== 'manifest.sha256').sort();
|
|
126
|
+
assert(JSON.stringify([...manifestEntries.keys()].sort()) === JSON.stringify(expectedManifestFiles), 'Manifest coverage is incomplete');
|
|
127
|
+
for (const [name, expectedHash] of manifestEntries) {
|
|
128
|
+
const actualHash = createHash('sha256').update(await readFile(join(packageRoot, name))).digest('hex');
|
|
129
|
+
assert(actualHash === expectedHash, `Checksum mismatch: ${name}`);
|
|
134
130
|
}
|
|
135
131
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
async function assertImageArchive(name, expectedTag) {
|
|
133
|
+
const archive = join(packageRoot, name);
|
|
134
|
+
const { stdout } = await run('tar', ['-xOf', archive, 'manifest.json'], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 });
|
|
135
|
+
const parsed = JSON.parse(stdout);
|
|
136
|
+
assert(Array.isArray(parsed) && parsed.length === 1, `${name} must contain exactly one image manifest`);
|
|
137
|
+
assert(JSON.stringify(parsed[0].RepoTags) === JSON.stringify([expectedTag]), `${name} must contain only ${expectedTag}`);
|
|
138
|
+
const { stdout: configText } = await run('tar', ['-xOf', archive, parsed[0].Config], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 });
|
|
139
|
+
const config = JSON.parse(configText);
|
|
140
|
+
assert(`${config.os}/${config.architecture}` === env.get('DOCKER_PLATFORM'), `${name} platform must equal DOCKER_PLATFORM`);
|
|
141
141
|
}
|
|
142
|
+
await assertImageArchive('frontend-image.tar', `${env.get('PROJECT_NAME')}_frontend:latest`);
|
|
143
|
+
await assertImageArchive('backend-image.tar', `${env.get('PROJECT_NAME')}_backend:latest`);
|
|
142
144
|
|
|
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
|
-
}
|
|
145
|
+
process.stdout.write(`valid unified Docker delivery package for ${env.get('PROJECT_NAME')}\n`);
|