@wdyy/skills 0.1.5 → 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 (24) hide show
  1. package/.well-known/skills/index.json +3 -3
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +51 -46
  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 +133 -7
  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 +201 -32
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +548 -51
  9. package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +11 -4
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +835 -142
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +48 -12
  12. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +17 -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 -17
  17. package/.well-known/skills/wdyy-logging-standard/SKILL.md +8 -7
  18. package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +2 -2
  19. package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +4 -3
  20. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +50 -1
  21. package/.well-known/skills/wdyy-logging-standard/templates/logger.template.ts +7 -0
  22. package/README.md +2 -2
  23. package/lib/wdyy-cli.js +35 -9
  24. package/package.json +1 -1
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { lstat, readFile, readdir, stat } from 'node:fs/promises';
4
+ import { basename, join, relative, resolve, sep } from 'node:path';
3
5
 
4
- const [deployPath, nginxPath, composePath] = process.argv.slice(2);
5
- if (!deployPath || !nginxPath || !composePath) {
6
- throw new Error('Pass deploy.sh, nginx config and compose file paths');
6
+ const [deployPath, nginxPath, composePath, packageRoot] = process.argv.slice(2);
7
+ if (!deployPath || !nginxPath || !composePath || !packageRoot) {
8
+ throw new Error('Pass deploy.sh, Nginx template, Compose file and generated deploy directory paths');
7
9
  }
8
10
 
9
11
  const [deploy, nginx, compose] = await Promise.all([
@@ -12,37 +14,204 @@ const [deploy, nginx, compose] = await Promise.all([
12
14
  readFile(composePath, 'utf8'),
13
15
  ]);
14
16
 
15
- const deployRequirements = [
16
- 'start)',
17
- 'stop)',
18
- 'restart)',
19
- 'status)',
20
- 'rollback)',
21
- 'version="$2"',
22
- 'HEALTH_URL',
23
- 'VERSION_URL',
24
- 'database/migrations',
25
- ];
26
- const nginxRequirements = ['location /releases/', '/current'];
27
- const composeRequirements = [
28
- 'backend-blue:',
29
- 'backend-green:',
30
- 'INSTANCE_ID',
31
- 'blue',
32
- 'green',
33
- 'LOG_DIR',
17
+ function requirePattern(text, pattern, message) {
18
+ if (!pattern.test(text)) throw new Error(message);
19
+ }
20
+
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;
37
+ }
38
+
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`,
48
+ );
49
+ }
50
+ requirePattern(deploy, /^ rollback\) \[\[ "\$#" -eq 2 \]\] \|\| usage;/m, 'rollback must require exactly one version');
51
+
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/,
34
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');
35
80
 
36
- const missing = [
37
- ...deployRequirements.filter((value) => !deploy.includes(value)),
38
- ...nginxRequirements.filter((value) => !nginx.includes(value)),
39
- ...composeRequirements.filter((value) => !compose.includes(value)),
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}`);
96
+ }
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}`);
100
+ }
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');
103
+ }
104
+ if (/listen\s+\d+/.test(nginx) || /proxy_pass\s+https?:\/\/(?!__)/.test(nginx)) throw new Error('Nginx URL or port is hardcoded');
105
+
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',
40
120
  ];
41
- if (missing.length) {
42
- throw new Error(`Missing deployment requirements: ${missing.join(', ')}`);
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}`);
125
+ }
126
+ }
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`);
43
131
  }
44
- if (/\bscp\b/.test(deploy)) {
45
- throw new Error('deploy.sh must not upload artifacts; SCP is a manual engineer action');
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`);
134
+ }
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');
147
+ }
148
+
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');
172
+ }
173
+
174
+ const versionFiles = (await listFiles(versionRoot)).sort();
175
+ for (const path of versionFiles) {
176
+ const name = basename(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}`);
179
+ }
180
+ }
181
+ const manifest = await readFile(join(versionRoot, 'manifest.sha256'), 'utf8');
182
+ const entries = new Map();
183
+ for (const line of manifest.trimEnd().split('\n')) {
184
+ const match = line.match(/^([0-9a-f]{64}) ([^/].*)$/);
185
+ if (!match || match[2].split('/').includes('..')) throw new Error(`Invalid manifest entry: ${line}`);
186
+ if (entries.has(match[2])) throw new Error(`Duplicate manifest entry: ${match[2]}`);
187
+ entries.set(match[2], match[1]);
188
+ }
189
+ assertDeepEqual([...entries.keys()].sort(), versionFiles.filter((path) => path !== 'manifest.sha256'), 'Manifest coverage is incomplete');
190
+ for (const [path, expected] of entries) {
191
+ const actual = createHash('sha256').update(await readFile(join(versionRoot, path))).digest('hex');
192
+ if (actual !== expected) throw new Error(`Checksum mismatch: ${path}`);
193
+ }
194
+
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);
46
206
  }
47
207
 
48
- process.stdout.write('valid deployment package\n');
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
+ }