@wdyy/skills 0.1.6 → 0.1.7

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 +53 -61
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +3 -3
  4. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +92 -88
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +213 -0
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +106 -0
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +168 -147
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +485 -526
  9. package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +10 -8
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +598 -416
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +46 -12
  12. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +2 -0
  13. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +30 -0
  14. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +24 -0
  15. package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +7 -0
  16. package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +17 -18
  17. package/README.md +2 -2
  18. package/lib/wdyy-cli.js +35 -9
  19. package/package.json +1 -1
@@ -0,0 +1,106 @@
1
+ import assert from 'node:assert/strict';
2
+ import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ 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';
7
+
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 = [];
11
+
12
+ afterEach(async () => {
13
+ await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
14
+ });
15
+
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);
23
+ return root;
24
+ }
25
+
26
+ function run(root, ...args) {
27
+ return spawnSync(process.execPath, [script, '--target', root, ...args], { encoding: 'utf8' });
28
+ }
29
+
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'), /build\|start\|replace/);
38
+ const environment = await readFile(join(root, '.env'), 'utf8');
39
+ const example = await readFile(join(root, '.env.example'), 'utf8');
40
+ assert.match(environment, /^# 前端\nFRONTEND_URL=0\.0\.0\.0\nFRONTEND_PORT=5173$/m);
41
+ assert.match(environment, /^# 部署\nPROJECT_NAME=\nSERVER_PROJECTS_ROOT=/m);
42
+ assert.equal(example, environment);
43
+ if (process.platform !== 'win32') assert.equal((await stat(join(root, '.env'))).mode & 0o777, 0o600);
44
+ });
45
+
46
+ test('扩充部署段时保留 .env 的真实基础值', async () => {
47
+ const root = await projectRoot();
48
+ const envPath = join(root, '.env');
49
+ await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('DB_PASSWORD=', 'DB_PASSWORD=private-value'), { mode: 0o600 });
50
+ const generated = run(root, '--write');
51
+ assert.equal(generated.status, 0, generated.stderr);
52
+ assert.match(await readFile(envPath, 'utf8'), /^DB_PASSWORD=private-value$/m);
53
+ });
54
+
55
+ test('非标准环境文件被拒绝且不修改', async () => {
56
+ const root = await projectRoot();
57
+ const envPath = join(root, '.env');
58
+ await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('# 数据库', '# 数据'), { mode: 0o600 });
59
+ const before = await readFile(envPath, 'utf8');
60
+ const generated = run(root, '--write');
61
+ assert.notEqual(generated.status, 0);
62
+ assert.match(generated.stderr, /section order/);
63
+ assert.equal(await readFile(envPath, 'utf8'), before);
64
+ });
65
+
66
+ test('.env 与 .env.example 变量名不一致时拒绝修改', async () => {
67
+ const root = await projectRoot();
68
+ const examplePath = join(root, '.env.example');
69
+ await writeFile(examplePath, (await readFile(examplePath, 'utf8')).replace('# API\n', '# API\nAPI_URL=\n'));
70
+ const envBefore = await readFile(join(root, '.env'), 'utf8');
71
+ const exampleBefore = await readFile(examplePath, 'utf8');
72
+ const generated = run(root, '--write');
73
+ assert.notEqual(generated.status, 0);
74
+ assert.match(generated.stderr, /variable names must remain consistent/);
75
+ assert.equal(await readFile(join(root, '.env'), 'utf8'), envBefore);
76
+ assert.equal(await readFile(examplePath, 'utf8'), exampleBefore);
77
+ });
78
+
79
+ test('差异文件默认拒绝覆盖,显式 force 后恢复一致', async () => {
80
+ const root = await projectRoot();
81
+ assert.equal(run(root, '--write').status, 0);
82
+ await writeFile(join(root, 'deploy.sh'), '# changed\n');
83
+ await chmod(join(root, 'deploy.sh'), 0o755);
84
+ const refused = run(root, '--write');
85
+ assert.notEqual(refused.status, 0);
86
+ assert.match(refused.stderr, /Refusing to overwrite/);
87
+ assert.equal(await readFile(join(root, 'deploy.sh'), 'utf8'), '# changed\n');
88
+ const forced = run(root, '--write', '--force');
89
+ assert.equal(forced.status, 0, forced.stderr);
90
+ assert.equal(run(root, '--check').status, 0);
91
+ });
92
+
93
+ test('重复或冲突参数被明确拒绝', async () => {
94
+ const root = await projectRoot();
95
+ const duplicateMode = run(root, '--write', '--check');
96
+ assert.notEqual(duplicateMode.status, 0);
97
+ assert.match(duplicateMode.stderr, /Exactly one of --write or --check is required/);
98
+
99
+ const duplicateTarget = spawnSync(
100
+ process.execPath,
101
+ [script, '--target', root, '--target', root, '--check'],
102
+ { encoding: 'utf8' },
103
+ );
104
+ assert.notEqual(duplicateTarget.status, 0);
105
+ assert.match(duplicateTarget.stderr, /Duplicate or missing --target argument/);
106
+ });
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from 'node:crypto';
3
- import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { lstat, readFile, readdir, stat } from 'node:fs/promises';
4
4
  import { basename, join, relative, resolve, sep } from 'node:path';
5
5
 
6
6
  const [deployPath, nginxPath, composePath, packageRoot] = process.argv.slice(2);
7
7
  if (!deployPath || !nginxPath || !composePath || !packageRoot) {
8
- throw new Error('Pass deploy.sh, Nginx config, compose file and generated deploy directory paths');
8
+ throw new Error('Pass deploy.sh, Nginx template, Compose file and generated deploy directory paths');
9
9
  }
10
10
 
11
11
  const [deploy, nginx, compose] = await Promise.all([
@@ -14,183 +14,204 @@ const [deploy, nginx, compose] = await Promise.all([
14
14
  readFile(composePath, 'utf8'),
15
15
  ]);
16
16
 
17
- const deployRequirements = [
18
- 'build)',
19
- 'start)',
20
- 'stop)',
21
- 'restart)',
22
- 'status)',
23
- 'rollback)',
24
- 'AGENTS.md',
25
- '## 发布记录',
26
- '| 版本号 | 构建时间 |',
27
- 'release.env',
28
- 'RELEASE_VERSION',
29
- 'SCRIPT_DIR',
30
- 'manifest.sha256',
31
- 'pnpm test',
32
- 'pnpm lint',
33
- 'pnpm typecheck',
34
- 'pnpm build',
35
- 'docker build',
36
- 'docker save',
37
- '.dockerignore',
38
- 'database/migrations',
39
- 'scripts/apply-migrations.sh',
40
- 'HEALTH_URL',
41
- 'VERSION_URL',
42
- 'BLUE_HEALTH_URL',
43
- 'GREEN_HEALTH_URL',
44
- 'BLUE_VERSION_URL',
45
- 'GREEN_VERSION_URL',
46
- 'BLUE_UPSTREAM',
47
- 'GREEN_UPSTREAM',
48
- 'BACKEND_PROXY_URL',
49
- ];
50
- const nginxRequirements = [
51
- '__DEPLOY_ROOT__',
52
- '__BACKEND_PROXY_URL__',
53
- 'location /releases/',
54
- '/current',
55
- ];
56
- const projectRootLogMount = '../../logs:/app/logs';
57
- const composeRequirements = [
58
- 'backend-blue:',
59
- 'backend-green:',
60
- 'INSTANCE_ID',
61
- 'blue',
62
- 'green',
63
- ];
64
-
65
- const missing = [
66
- ...deployRequirements.filter((value) => !deploy.includes(value)),
67
- ...nginxRequirements.filter((value) => !nginx.includes(value)),
68
- ...composeRequirements.filter((value) => !compose.includes(value)),
69
- ];
70
- if (missing.length) {
71
- throw new Error(`Missing deployment requirements: ${missing.join(', ')}`);
17
+ function requirePattern(text, pattern, message) {
18
+ if (!pattern.test(text)) throw new Error(message);
72
19
  }
73
20
 
74
- if (!/start\)\s*\n\s*\[\[ "\$#" -eq 1 \]\] \|\| usage/.test(deploy)) {
75
- throw new Error('start must reject version arguments and read release.env');
76
- }
77
- if (/start\)[\s\S]{0,160}version="\$2"/.test(deploy) || deploy.includes('INCOMING_DIR')) {
78
- throw new Error('Legacy start <version> and incoming/<version> layouts are forbidden');
79
- }
80
- if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${nginx}\n${compose}`)) {
81
- throw new Error('Deployment root must come from deploy.sh physical location, not a fixed /srv path');
82
- }
83
- if (/(?:127\.0\.0\.1|\b3001\b|\b3002\b)/.test(`${deploy}\n${compose}`)) {
84
- throw new Error('Deployment URLs, ports and IP addresses must come from environment files');
85
- }
86
- if (/proxy_pass\s+https?:\/\//.test(nginx)) {
87
- throw new Error('Nginx proxy URL must come from BACKEND_PROXY_URL in .env.production');
88
- }
89
- if (/\b(?:scp|rsync)\b/.test(deploy)) {
90
- throw new Error('deploy.sh must not upload artifacts; upload is a manual engineer action');
21
+ function parseDotenv(text) {
22
+ const values = new Map();
23
+ for (const [index, raw] of text.replaceAll('\r\n', '\n').split('\n').entries()) {
24
+ if (!raw || /^\s*#/.test(raw)) continue;
25
+ const match = raw.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
26
+ if (!match) throw new Error(`Invalid root .env line ${index + 1}`);
27
+ if (values.has(match[1])) throw new Error(`Duplicate root .env key: ${match[1]}`);
28
+ let value = match[2];
29
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
30
+ value = value.slice(1, -1);
31
+ } else if (value.startsWith('"') || value.startsWith("'")) {
32
+ throw new Error(`Unclosed root .env quote: ${match[1]}`);
33
+ }
34
+ values.set(match[1], value);
35
+ }
36
+ return values;
91
37
  }
92
38
 
93
- const forbiddenLogConfiguration = /\b(?:LOG_DIR|HOST_LOG_DIR)\b|backend\/(?:blue|green)|\/var\/log\/application/;
94
- if (forbiddenLogConfiguration.test(compose)) {
95
- throw new Error(
96
- 'Logs must use the shared project-root ../../logs:/app/logs mount without configurable or per-instance directories',
39
+ const commands = ['build', 'start', 'replace', 'restart', 'stop', 'remove', 'status', 'rollback'];
40
+ for (const command of commands) {
41
+ requirePattern(deploy, new RegExp(`^ ${command}\\)`, 'm'), `Missing deployment command: ${command}`);
42
+ }
43
+ for (const command of commands.filter((item) => item !== 'rollback')) {
44
+ requirePattern(
45
+ deploy,
46
+ new RegExp(`^ ${command}\\) \\[\\[ "\\$#" -eq 1 \\]\\] \\|\\| usage;`, 'm'),
47
+ `${command} must reject extra arguments`,
97
48
  );
98
49
  }
99
- const logTargetMounts = compose.match(/^[ \t]*-[ \t]*[^#\n]+:\/app\/logs[ \t]*$/gm) ?? [];
100
- if (
101
- logTargetMounts.length !== 2
102
- || logTargetMounts.some((mount) => !mount.includes(projectRootLogMount))
103
- ) {
104
- throw new Error('Blue and green must both mount project-root ../../logs directly to /app/logs');
105
- }
50
+ requirePattern(deploy, /^ rollback\) \[\[ "\$#" -eq 2 \]\] \|\| usage;/m, 'rollback must require exactly one version');
106
51
 
107
- const releaseEnvPath = join(packageRoot, 'release.env');
108
- const releaseEnv = await readFile(releaseEnvPath, 'utf8');
109
- const releaseMatch = releaseEnv.match(/^RELEASE_VERSION=(\d{8}-\d{3})\n$/);
110
- if (!releaseMatch) {
111
- throw new Error('release.env must contain exactly one RELEASE_VERSION=YYYYMMDD-NNN line');
52
+ const requiredDeploySemantics = [
53
+ /release_ledger next/,
54
+ /pnpm test/,
55
+ /pnpm lint/,
56
+ /pnpm typecheck/,
57
+ /pnpm build/,
58
+ /frontend_image="\$\{PROJECT_NAME\}_frontend:\$version"/,
59
+ /backend_image="\$\{PROJECT_NAME\}_backend:\$version"/,
60
+ /docker save -o "\$version_root\/frontend-image\.tar"/,
61
+ /docker save -o "\$version_root\/backend-image\.tar"/,
62
+ /load_dotenv "\$DEPLOY_ROOT\/\.env"/,
63
+ /endpoint_url "\$DOCKER_BIND_IP"/,
64
+ /server deploy root must be exactly/,
65
+ /assert_project_ports_isolated/,
66
+ /flock -x 9/,
67
+ /Nginx reload failed; old project configuration restored/,
68
+ /database migration was not executed/,
69
+ /docker ps -aq --filter "label=com\.docker\.compose\.project=\$PROJECT_NAME"/,
70
+ /\$1 == frontend \|\| \$1 == backend/,
71
+ ];
72
+ for (const pattern of requiredDeploySemantics) requirePattern(deploy, pattern, `Missing deployment behavior: ${pattern}`);
73
+ if (/\bsource\s+["']?[^\n]*(?:\.env|ACTIVE_STATE|PREVIOUS_STATE)/.test(deploy)) {
74
+ throw new Error('Environment and state files must be parsed as data, not sourced');
75
+ }
76
+ if (/\beval\b/.test(deploy)) throw new Error('deploy.sh must not use eval');
77
+ if (/\bpsql\b/.test(deploy)) throw new Error('deploy.sh must not execute database clients');
78
+ if (/\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy)) throw new Error('deploy.sh must not upload or pull artifacts');
79
+ if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${nginx}\n${compose}`)) throw new Error('Fixed /srv deployment roots are forbidden');
80
+
81
+ const serviceNames = [...compose.matchAll(/^ ([a-z-]+):$/gm)].map((match) => match[1]);
82
+ assertDeepEqual(serviceNames, ['frontend-blue', 'backend-blue', 'frontend-green', 'backend-green'], 'Compose must define exactly four ordered blue-green services');
83
+ if (compose.includes(':-') || compose.includes('not-configured')) throw new Error('Compose image fallback is forbidden');
84
+ if ((compose.match(/image: \$\{FRONTEND_IMAGE:\?FRONTEND_IMAGE is required\}/g) ?? []).length !== 2) throw new Error('Both frontend services must require FRONTEND_IMAGE');
85
+ if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !== 2) throw new Error('Both backend services must require BACKEND_IMAGE');
86
+ if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !== 4) throw new Error('All four services must use root .env');
87
+ const logMounts = compose.match(/^\s*- \.\.\/\.\.\/logs:\/app\/logs\s*$/gm) ?? [];
88
+ if (logMounts.length !== 4) throw new Error('All four services must mount flat project-root logs');
89
+ if (/LOG_DIR|HOST_LOG_DIR|logs\/(?:frontend|backend|blue|green)/.test(compose)) throw new Error('Configurable or nested log directories are forbidden');
90
+ if (/container_name:/.test(compose)) throw new Error('container_name would weaken Compose project isolation');
91
+ if (/FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|_HEALTH_URL|_VERSION_URL/.test(compose)) {
92
+ throw new Error('Compose contains legacy duplicate URL or port configuration');
93
+ }
94
+ for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'FRONTEND_URL', 'BACKEND_URL']) {
95
+ if (!compose.includes(`\${${key}}`)) throw new Error(`Compose must use ${key}`);
112
96
  }
113
- const version = releaseMatch[1];
114
- const rootEntries = (await readdir(packageRoot)).sort();
115
- const expectedRootEntries = [version, 'deploy.sh', 'release.env'].sort();
116
- if (JSON.stringify(rootEntries) !== JSON.stringify(expectedRootEntries)) {
117
- throw new Error(`Generated deploy directory must contain only deploy.sh, release.env and ${version}`);
97
+
98
+ for (const placeholder of ['__PROJECT_NAME__', '__PROJECT_HTTP_PORT__', '__DOCKER_HOST_PORTS__', '__DEPLOY_ROOT__', '__FRONTEND_UPSTREAM_NAME__', '__BACKEND_UPSTREAM_NAME__']) {
99
+ if (!nginx.includes(placeholder)) throw new Error(`Nginx template is missing ${placeholder}`);
118
100
  }
119
- if (resolve(deployPath) !== resolve(packageRoot, 'deploy.sh')) {
120
- throw new Error('Validator deploy.sh path must be the generated deploy directory root script');
101
+ if (!/^# wdyy-project: __PROJECT_NAME__$/m.test(nginx) || !/^# wdyy-port: __PROJECT_HTTP_PORT__$/m.test(nginx) || !/^# wdyy-docker-ports: __DOCKER_HOST_PORTS__$/m.test(nginx)) {
102
+ throw new Error('Nginx template must expose deterministic project and all port markers');
121
103
  }
104
+ if (/listen\s+\d+/.test(nginx) || /proxy_pass\s+https?:\/\/(?!__)/.test(nginx)) throw new Error('Nginx URL or port is hardcoded');
122
105
 
123
- const versionRoot = join(packageRoot, version);
124
- const requiredPaths = [
125
- 'frontend.tar.gz',
126
- 'backend-image.tar',
127
- 'manifest.sha256',
128
- 'database/migrations',
129
- 'scripts/apply-migrations.sh',
130
- 'docker/docker-compose.blue-green.yml',
131
- 'nginx/site.conf',
106
+ const rootEnvPath = join(packageRoot, '.env');
107
+ const rootEnvStat = await stat(rootEnvPath);
108
+ if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0) {
109
+ throw new Error('Root .env must be a private owner-readable file');
110
+ }
111
+ const rootEnvText = await readFile(rootEnvPath, 'utf8');
112
+ const env = parseDotenv(rootEnvText);
113
+ const sectionNames = [...rootEnvText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
114
+ if (sectionNames.join('|') !== '前端|后端|数据库|API|部署') throw new Error('Root .env must use the five standard sections in order');
115
+ const requiredEnv = [
116
+ 'FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT',
117
+ 'PROJECT_NAME', 'SERVER_PROJECTS_ROOT', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
118
+ 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
119
+ 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE', 'DATABASE_MIGRATION_MODE',
132
120
  ];
133
- for (const requiredPath of requiredPaths) {
134
- try {
135
- await stat(join(versionRoot, requiredPath));
136
- } catch {
137
- throw new Error(`Missing generated release package path: ${requiredPath}`);
121
+ for (const key of requiredEnv) if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
122
+ for (const key of env.keys()) {
123
+ if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
124
+ throw new Error(`Root .env contains legacy duplicate configuration: ${key}`);
138
125
  }
139
126
  }
140
-
141
- const runner = await stat(join(versionRoot, 'scripts/apply-migrations.sh'));
142
- if (!runner.isFile() || (runner.mode & 0o111) === 0) {
143
- throw new Error('Generated migration runner must be an executable file');
127
+ if (!/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME'))) throw new Error('Invalid PROJECT_NAME');
128
+ if (!/^\/.+[^/]$/.test(env.get('SERVER_PROJECTS_ROOT'))) throw new Error('Invalid SERVER_PROJECTS_ROOT');
129
+ for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) {
130
+ if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
131
+ }
132
+ for (const key of ['FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
133
+ if (!/@sha256:[0-9a-f]{64}$/.test(env.get(key))) throw new Error(`${key} must be pinned by digest`);
144
134
  }
145
- const migrationEntries = await readdir(join(versionRoot, 'database/migrations'));
146
- if (!migrationEntries.some((entry) => /^V\d{3}__[a-z0-9]+(?:_[a-z0-9]+)*\.sql$/.test(entry))) {
147
- throw new Error('Generated release package must include versioned database migrations');
135
+ const migrationMode = env.get('DATABASE_MIGRATION_MODE');
136
+ if (!['manual', 'none'].includes(migrationMode)) throw new Error('DATABASE_MIGRATION_MODE must be manual or none');
137
+ if (env.has('LOG_DIR') || env.has('HOST_LOG_DIR')) throw new Error('Root .env must not configure log directories');
138
+ const allPortKeys = ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
139
+ for (const key of allPortKeys) {
140
+ const port = Number(env.get(key));
141
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${key} must be a valid port`);
142
+ }
143
+ const hostPortKeys = ['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
144
+ const hostPorts = hostPortKeys.map((key) => Number(env.get(key)));
145
+ if (hostPorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535) || new Set(hostPorts).size !== hostPorts.length) {
146
+ throw new Error('Project and host blue-green ports must be valid and unique');
148
147
  }
149
148
 
150
- async function listFiles(root, directory = root) {
151
- const result = [];
152
- for (const entry of await readdir(directory, { withFileTypes: true })) {
153
- const path = join(directory, entry.name);
154
- if (entry.isSymbolicLink()) {
155
- throw new Error(`Generated release package must not contain symbolic links: ${relative(root, path)}`);
156
- }
157
- if (entry.isDirectory()) result.push(...await listFiles(root, path));
158
- else if (entry.isFile()) result.push(relative(root, path).split(sep).join('/'));
159
- }
160
- return result;
149
+ const releaseEnv = await readFile(join(packageRoot, 'release.env'), 'utf8');
150
+ const releaseMatch = releaseEnv.match(/^RELEASE_VERSION=(\d{8}-\d{3})\n$/);
151
+ if (!releaseMatch) throw new Error('release.env must contain exactly one release version');
152
+ const version = releaseMatch[1];
153
+ const expectedRootEntries = ['.env', version, 'deploy.sh', 'release.env'].sort();
154
+ assertDeepEqual((await readdir(packageRoot)).sort(), expectedRootEntries, 'Generated deploy root has unexpected entries');
155
+ if (resolve(deployPath) !== resolve(packageRoot, 'deploy.sh')) throw new Error('deploy.sh must be the package root script');
156
+ if (resolve(nginxPath) !== resolve(packageRoot, version, 'nginx/site.conf')) throw new Error('Nginx path must be inside the selected version');
157
+ if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.blue-green.yml')) throw new Error('Compose path must be inside the selected version');
158
+
159
+ const versionRoot = join(packageRoot, version);
160
+ const requiredPaths = ['frontend-image.tar', 'backend-image.tar', 'manifest.sha256', 'docker/docker-compose.blue-green.yml', 'nginx/site.conf'];
161
+ for (const path of requiredPaths) {
162
+ const info = await stat(join(versionRoot, path));
163
+ if (!info.isFile()) throw new Error(`Required release path is not a file: ${path}`);
164
+ }
165
+ if (migrationMode === 'manual') {
166
+ const runner = await stat(join(versionRoot, 'database/apply-migrations.sh'));
167
+ if (!runner.isFile() || (runner.mode & 0o111) === 0) throw new Error('Manual migration runner must be executable');
168
+ const migrations = await readdir(join(versionRoot, 'database/migrations'));
169
+ if (!migrations.some((item) => /^V\d{3}__[a-z0-9]+(?:_[a-z0-9]+)*\.sql$/.test(item))) throw new Error('Manual migration files are missing');
170
+ } else {
171
+ await assertMissing(join(versionRoot, 'database'), 'none mode must not package database files');
161
172
  }
162
173
 
163
174
  const versionFiles = (await listFiles(versionRoot)).sort();
164
175
  for (const path of versionFiles) {
165
176
  const name = basename(path);
166
- if (
167
- name === '.env'
168
- || name.startsWith('.env.')
169
- || /\.(?:pem|key)$/.test(name)
170
- || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)
171
- ) {
172
- throw new Error(`Production secret file must not be packaged: ${path}`);
177
+ if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) {
178
+ throw new Error(`Secret file must not enter version directory: ${path}`);
173
179
  }
174
180
  }
175
-
176
- const manifestPath = join(versionRoot, 'manifest.sha256');
177
- const manifest = await readFile(manifestPath, 'utf8');
181
+ const manifest = await readFile(join(versionRoot, 'manifest.sha256'), 'utf8');
178
182
  const entries = new Map();
179
183
  for (const line of manifest.trimEnd().split('\n')) {
180
184
  const match = line.match(/^([0-9a-f]{64}) ([^/].*)$/);
181
- if (!match || match[2].split('/').includes('..')) {
182
- throw new Error(`Invalid manifest entry: ${line}`);
183
- }
185
+ if (!match || match[2].split('/').includes('..')) throw new Error(`Invalid manifest entry: ${line}`);
184
186
  if (entries.has(match[2])) throw new Error(`Duplicate manifest entry: ${match[2]}`);
185
187
  entries.set(match[2], match[1]);
186
188
  }
187
- const expectedManifestFiles = versionFiles.filter((path) => path !== 'manifest.sha256');
188
- if (JSON.stringify([...entries.keys()].sort()) !== JSON.stringify(expectedManifestFiles)) {
189
- throw new Error('manifest.sha256 must cover every generated release file exactly once');
190
- }
189
+ assertDeepEqual([...entries.keys()].sort(), versionFiles.filter((path) => path !== 'manifest.sha256'), 'Manifest coverage is incomplete');
191
190
  for (const [path, expected] of entries) {
192
191
  const actual = createHash('sha256').update(await readFile(join(versionRoot, path))).digest('hex');
193
192
  if (actual !== expected) throw new Error(`Checksum mismatch: ${path}`);
194
193
  }
195
194
 
196
- process.stdout.write(`valid deployment package ${version}\n`);
195
+ process.stdout.write(`valid dual-image deployment package ${version}\n`);
196
+
197
+ function assertDeepEqual(actual, expected, message) {
198
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
199
+ throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
200
+ }
201
+ }
202
+
203
+ async function assertMissing(path, message) {
204
+ try { await lstat(path); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
205
+ throw new Error(message);
206
+ }
207
+
208
+ async function listFiles(root, directory = root) {
209
+ const result = [];
210
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
211
+ const path = join(directory, entry.name);
212
+ if (entry.isSymbolicLink()) throw new Error(`Release package must not contain symbolic links: ${relative(root, path)}`);
213
+ if (entry.isDirectory()) result.push(...await listFiles(root, path));
214
+ else if (entry.isFile()) result.push(relative(root, path).split(sep).join('/'));
215
+ }
216
+ return result;
217
+ }