@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.
Files changed (19) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +30 -46
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
  4. package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +103 -0
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +138 -155
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +97 -97
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +93 -116
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +185 -311
  9. package/.well-known/skills/wdyy-deployment-standard/templates/{Dockerfile.template → backend.Dockerfile.template} +5 -9
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +210 -256
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +11 -13
  12. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
  13. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -19
  14. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +0 -6
  15. package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +9 -4
  16. package/README.md +2 -2
  17. package/lib/wdyy-cli.js +2 -1
  18. package/package.json +1 -1
  19. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +0 -99
@@ -1,120 +1,120 @@
1
+ import test from 'node:test';
1
2
  import assert from 'node:assert/strict';
2
- import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
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 { afterEach, test } from 'node:test';
6
- import { spawnSync } from 'node:child_process';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { promisify } from 'node:util';
7
9
 
8
- const script = new URL('./generate-deployment-files.mjs', import.meta.url).pathname;
9
- const environmentTemplate = new URL('../templates/env.example.template', import.meta.url).pathname;
10
- const temporaryDirectories = [];
10
+ const run = promisify(execFile);
11
+ const script = join(dirname(fileURLToPath(import.meta.url)), 'generate-deployment-files.mjs');
12
+ const created = [];
11
13
 
12
- afterEach(async () => {
13
- await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
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 projectRoot() {
17
- const root = await mkdtemp(join(tmpdir(), 'deployment-generator-'));
18
- temporaryDirectories.push(root);
19
- const template = await readFile(environmentTemplate, 'utf8');
20
- const initializedEnvironment = `${template.slice(0, template.indexOf('# 部署') + '# 部署'.length)}\n`;
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 run(root, ...args) {
27
- return spawnSync(process.execPath, [script, '--target', root, ...args], { encoding: 'utf8' });
57
+ async function invoke(root, ...args) {
58
+ return run(process.execPath, [script, '--target', root, ...args]);
28
59
  }
29
60
 
30
- test('确定性生成全部标准文件并通过 check', async () => {
31
- const root = await projectRoot();
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('扩充部署段时保留 .env 的真实基础值', async () => {
52
- const root = await projectRoot();
53
- const envPath = join(root, '.env');
54
- await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('DB_PASSWORD=', 'DB_PASSWORD=private-value'), { mode: 0o600 });
55
- const generated = run(root, '--write');
56
- assert.equal(generated.status, 0, generated.stderr);
57
- assert.match(await readFile(envPath, 'utf8'), /^DB_PASSWORD=private-value$/m);
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('非标准环境文件被拒绝且不修改', async () => {
61
- const root = await projectRoot();
62
- const envPath = join(root, '.env');
63
- await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('# 数据库', '# 数据'), { mode: 0o600 });
64
- const before = await readFile(envPath, 'utf8');
65
- const generated = run(root, '--write');
66
- assert.notEqual(generated.status, 0);
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('.env 与 .env.example 变量名不一致时拒绝修改', async () => {
72
- const root = await projectRoot();
73
- const examplePath = join(root, '.env.example');
74
- await writeFile(examplePath, (await readFile(examplePath, 'utf8')).replace('# API\n', '# API\nAPI_URL=\n'));
75
- const envBefore = await readFile(join(root, '.env'), 'utf8');
76
- const exampleBefore = await readFile(examplePath, 'utf8');
77
- const generated = run(root, '--write');
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('差异文件默认拒绝覆盖,显式 force 后恢复一致', async () => {
85
- const root = await projectRoot();
86
- assert.equal(run(root, '--write').status, 0);
87
- await writeFile(join(root, 'deploy.sh'), '# changed\n');
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('重复或冲突参数被明确拒绝', async () => {
99
- const root = await projectRoot();
100
- const duplicateMode = run(root, '--write', '--check');
101
- assert.notEqual(duplicateMode.status, 0);
102
- assert.match(duplicateMode.stderr, /Exactly one of --write or --check is required/);
103
-
104
- const duplicateTarget = spawnSync(
105
- process.execPath,
106
- [script, '--target', root, '--target', root, '--check'],
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('旧蓝绿和宿主机 Nginx 配置键被明确拒绝', async () => {
114
- const root = await projectRoot();
115
- const envPath = join(root, '.env');
116
- await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('# 部署\n', '# 部署\nPROJECT_HTTP_PORT=9099\n'), { mode: 0o600 });
117
- const generated = run(root, '--write');
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, relative, resolve, sep } from 'node:path';
5
+ import { basename, join, resolve } from 'node:path';
6
+ import { promisify } from 'node:util';
5
7
 
6
- const [deployPath, composePath, packageRoot] = process.argv.slice(2);
7
- if (!deployPath || !composePath || !packageRoot || process.argv.length !== 5) {
8
- throw new Error('Pass deploy.sh, Compose file and generated deploy directory paths');
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 requirePattern(text, pattern, message) {
14
- if (!pattern.test(text)) throw new Error(message);
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
- for (const [pattern, message] of [
37
- [/if \[\[ "\$#" -eq 0 \]\]; then server_menu/, 'No-argument execution must open the server menu'],
38
- [/elif \[\[ "\$#" -eq 1 && "\$1" == build \]\]; then build_release/, 'build must be the only command-line operation'],
39
- [/'1\. 运行或替换前端'/, 'Missing frontend menu item'],
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
- for (const key of env.keys()) {
89
- if (['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'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
90
- throw new Error(`Root .env contains removed deployment configuration: ${key}`);
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
- if (!/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME'))) throw new Error('Invalid PROJECT_NAME');
94
- for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
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
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${key} must be a valid port`);
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 releaseEnv = await readFile(join(packageRoot, 'release.env'), 'utf8');
102
- const releaseMatch = releaseEnv.match(/^RELEASE_VERSION=(\d{8}-\d{3})\n$/);
103
- if (!releaseMatch) throw new Error('release.env must contain exactly one release version');
104
- const version = releaseMatch[1];
105
- assertDeepEqual((await readdir(packageRoot)).sort(), ['.env', version, 'deploy.sh', 'release.env'].sort(), 'Generated deploy root has unexpected entries');
106
- if (resolve(deployPath) !== resolve(packageRoot, 'deploy.sh')) throw new Error('deploy.sh must be the package root script');
107
- if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.yml')) throw new Error('Compose path must be inside the selected version');
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 versionRoot = join(packageRoot, version);
110
- for (const path of ['frontend-image.tar', 'backend-image.tar', 'manifest.sha256', 'docker/docker-compose.yml']) {
111
- const info = await stat(join(versionRoot, path));
112
- if (!info.isFile()) throw new Error(`Required release path is not a file: ${path}`);
113
- }
114
- await assertMissing(join(versionRoot, 'database'), 'Release package must not contain database migration files');
115
- await assertMissing(join(versionRoot, 'nginx'), 'Release package must not contain host Nginx files');
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 versionFiles = (await listFiles(versionRoot)).sort();
118
- for (const path of versionFiles) {
119
- const name = basename(path);
120
- if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) throw new Error(`Secret file must not enter version directory: ${path}`);
121
- }
122
- const manifest = await readFile(join(versionRoot, 'manifest.sha256'), 'utf8');
123
- const entries = new Map();
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
- assertDeepEqual([...entries.keys()].sort(), versionFiles.filter((path) => path !== 'manifest.sha256'), 'Manifest coverage is incomplete');
131
- for (const [path, expected] of entries) {
132
- const actual = createHash('sha256').update(await readFile(join(versionRoot, path))).digest('hex');
133
- if (actual !== expected) throw new Error(`Checksum mismatch: ${path}`);
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
- process.stdout.write(`valid direct dual-image deployment package ${version}\n`);
137
-
138
- async function assertMissing(path, message) {
139
- try { await lstat(path); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
140
- throw new Error(message);
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
- async function listFiles(root, directory = root) {
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`);