@wdyy/skills 0.1.10 → 0.1.12

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 (21) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +33 -44
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
  4. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +27 -63
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +19 -9
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +16 -2
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +61 -123
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +145 -347
  9. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +184 -675
  10. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +35 -0
  11. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -7
  12. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -2
  13. package/.well-known/skills/wdyy-logging-standard/SKILL.md +2 -2
  14. package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
  15. package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +2 -2
  16. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +1 -1
  17. package/README.md +4 -2
  18. package/lib/wdyy-cli.js +3 -2
  19. package/package.json +1 -1
  20. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +0 -70
  21. package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +0 -26
@@ -1,8 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { createHash } from 'node:crypto';
3
- import {
4
- access, chmod, cp, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, unlink, writeFile,
5
- } from 'node:fs/promises';
3
+ import { access, chmod, cp, copyFile, mkdir, mkdtemp, readFile, readdir, rm, symlink, unlink, writeFile } from 'node:fs/promises';
6
4
  import { tmpdir } from 'node:os';
7
5
  import { dirname, join, resolve } from 'node:path';
8
6
  import { fileURLToPath } from 'node:url';
@@ -14,12 +12,7 @@ const skillRoot = resolve(scriptsRoot, '..');
14
12
  const templatesRoot = join(skillRoot, 'templates');
15
13
  const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
16
14
  const deployTemplate = join(templatesRoot, 'deploy.sh.template');
17
- const composeTemplate = join(templatesRoot, 'docker-compose.blue-green.yml');
18
- const nginxTemplate = join(templatesRoot, 'nginx-upstream.template.conf');
19
- const dockerignoreTemplate = join(templatesRoot, 'dockerignore.template');
20
- const frontendDockerfileTemplate = join(templatesRoot, 'frontend.Dockerfile.template');
21
- const backendDockerfileTemplate = join(templatesRoot, 'Dockerfile.template');
22
- const frontendNginxTemplate = join(templatesRoot, 'frontend-container.conf.template');
15
+ const composeTemplate = join(templatesRoot, 'docker-compose.yml');
23
16
  const temporaryDirectories = [];
24
17
 
25
18
  afterEach(async () => {
@@ -32,7 +25,7 @@ async function temporary(prefix) {
32
25
  return path;
33
26
  }
34
27
 
35
- function envText(serverProjectsRoot, mode = 'none', extra = '') {
28
+ function envText(extra = '') {
36
29
  return `# 前端
37
30
  FRONTEND_URL=0.0.0.0
38
31
  FRONTEND_PORT=5173
@@ -53,61 +46,46 @@ DB_SCHEMA=
53
46
 
54
47
  # 部署
55
48
  PROJECT_NAME=fixture
56
- SERVER_PROJECTS_ROOT=${serverProjectsRoot}
57
- PROJECT_HTTP_PORT=9099
58
49
  DOCKER_BIND_IP=127.0.0.1
59
- FRONTEND_BLUE_PORT=19091
60
- FRONTEND_GREEN_PORT=19092
61
- BACKEND_BLUE_PORT=19093
62
- BACKEND_GREEN_PORT=19094
63
50
  FRONTEND_BASE_IMAGE=nginx:stable
64
51
  BACKEND_BASE_IMAGE=node:24-alpine3.24
65
- DATABASE_MIGRATION_MODE=${mode}
66
52
  ${extra}`;
67
53
  }
68
54
 
69
- async function createFixture({ mode = 'none', agents = '# Target project\n', extraEnv = '' } = {}) {
70
- const root = await temporary('wdyy-deploy-');
55
+ async function writeExecutable(path, content) {
56
+ await writeFile(path, content);
57
+ await chmod(path, 0o755);
58
+ }
59
+
60
+ async function createFixture({ agents = '# Target project\n', extraEnv = '' } = {}) {
61
+ const root = await temporary('wdyy-direct-deploy-');
71
62
  const fakeBin = join(root, 'fake-bin');
72
- const serverProjectsRoot = join(root, 'servers');
73
63
  const commandLog = join(root, 'commands.log');
74
64
  await Promise.all([
75
65
  mkdir(fakeBin, { recursive: true }),
76
66
  mkdir(join(root, 'src/frontend'), { recursive: true }),
77
67
  mkdir(join(root, 'src/backend'), { recursive: true }),
78
68
  mkdir(join(root, 'scripts/deployment'), { recursive: true }),
79
- mkdir(serverProjectsRoot, { recursive: true }),
80
69
  ]);
81
70
  await Promise.all([
82
71
  copyFile(deployTemplate, join(root, 'deploy.sh')),
83
- copyFile(frontendDockerfileTemplate, join(root, 'src/frontend/Dockerfile')),
84
- copyFile(backendDockerfileTemplate, join(root, 'src/backend/Dockerfile')),
85
- copyFile(composeTemplate, join(root, 'scripts/deployment/docker-compose.blue-green.yml')),
86
- copyFile(nginxTemplate, join(root, 'scripts/deployment/nginx-site.conf')),
87
- copyFile(frontendNginxTemplate, join(root, 'scripts/deployment/frontend-container.conf.template')),
88
- copyFile(dockerignoreTemplate, join(root, '.dockerignore')),
72
+ copyFile(join(templatesRoot, 'frontend.Dockerfile.template'), join(root, 'src/frontend/Dockerfile')),
73
+ copyFile(join(templatesRoot, 'Dockerfile.template'), join(root, 'src/backend/Dockerfile')),
74
+ copyFile(composeTemplate, join(root, 'scripts/deployment/docker-compose.yml')),
75
+ copyFile(join(templatesRoot, 'frontend-container.conf.template'), join(root, 'scripts/deployment/frontend-container.conf.template')),
76
+ copyFile(join(templatesRoot, 'dockerignore.template'), join(root, '.dockerignore')),
89
77
  writeFile(join(root, 'AGENTS.md'), agents),
90
- writeFile(join(root, '.env'), envText(serverProjectsRoot, mode, extraEnv)),
78
+ writeFile(join(root, '.env'), envText(extraEnv)),
91
79
  writeFile(commandLog, ''),
92
80
  ]);
93
81
  await chmod(join(root, 'deploy.sh'), 0o755);
94
82
  await chmod(join(root, '.env'), 0o600);
95
83
 
96
- if (mode === 'manual') {
97
- await mkdir(join(root, 'database/migrations'), { recursive: true });
98
- await writeFile(join(root, 'database/migrations/V001__baseline.sql'), 'SELECT 1;\n');
99
- await writeFile(join(root, 'scripts/apply-migrations.sh'), '#!/usr/bin/env bash\nset -euo pipefail\n');
100
- await chmod(join(root, 'scripts/apply-migrations.sh'), 0o755);
101
- }
102
-
103
84
  await writeExecutable(join(fakeBin, 'pnpm'), `#!/usr/bin/env bash
104
85
  set -euo pipefail
105
86
  printf 'pnpm %s\n' "$*" >> "$COMMAND_LOG"
106
87
  [[ "\${FAIL_GATE:-}" != "\${1:-}" ]] || exit 9
107
- if [[ "\${1:-}" == build ]]; then
108
- mkdir -p src/frontend/dist
109
- printf '<!doctype html><div id="app"></div>\n' > src/frontend/dist/index.html
110
- fi
88
+ if [[ "\${1:-}" == build ]]; then mkdir -p src/frontend/dist; printf '<div id="app"></div>\n' > src/frontend/dist/index.html; fi
111
89
  `);
112
90
  await writeExecutable(join(fakeBin, 'docker'), `#!/usr/bin/env bash
113
91
  set -euo pipefail
@@ -115,89 +93,41 @@ printf 'docker %s\n' "$*" >> "$COMMAND_LOG"
115
93
  case "\${1:-}" in
116
94
  build) exit 0 ;;
117
95
  image)
118
- if [[ "\${2:-}" == inspect ]]; then tag="\${!#}"; printf '%s\n' "\${tag##*:}";
119
- elif [[ "\${2:-}" == ls ]]; then printf '%s\n' "\${DOCKER_IMAGE_LIST:-fixture_frontend:20260808-001
120
- fixture_backend:20260808-001
121
- other_project_frontend:shared}"; fi
96
+ [[ "\${2:-}" == inspect ]] || exit 10
97
+ tag="\${!#}"; printf '%s\n' "\${tag##*:}"
122
98
  ;;
123
99
  save)
124
100
  shift; output=; tag=
125
- while [[ "$#" -gt 0 ]]; do
126
- if [[ "$1" == -o ]]; then output="$2"; shift 2; else tag="$1"; shift; fi
127
- done
128
- work="$(mktemp -d)"
129
- printf '[{"Config":"config.json","RepoTags":["%s"],"Layers":[]}]\n' "$tag" > "$work/manifest.json"
130
- printf '{}\n' > "$work/config.json"
131
- /usr/bin/tar -cf "$output" -C "$work" manifest.json config.json
132
- /bin/rm -rf "$work"
101
+ while [[ "$#" -gt 0 ]]; do if [[ "$1" == -o ]]; then output="$2"; shift 2; else tag="$1"; shift; fi; done
102
+ work="$(mktemp -d)"; printf '[{"Config":"config.json","RepoTags":["%s"],"Layers":[]}]\n' "$tag" > "$work/manifest.json"; printf '{}\n' > "$work/config.json"
103
+ /usr/bin/tar -cf "$output" -C "$work" manifest.json config.json; /bin/rm -rf "$work"
133
104
  ;;
134
105
  load) exit 0 ;;
135
106
  compose) exit 0 ;;
136
- ps)
137
- if [[ "\${2:-}" == -aq ]]; then
138
- printf '%s\n' "\${DOCKER_CONTAINER_IDS:-c111
139
- c222}"
140
- elif [[ "\${2:-}" == --format && -n "\${DOCKER_RUNNING_PORTS:-}" ]]; then
141
- printf '%s\n' "$DOCKER_RUNNING_PORTS"
142
- fi
143
- ;;
144
- network)
145
- if [[ "\${2:-}" == ls ]]; then printf '%s\n' "\${DOCKER_NETWORK_IDS:-n111}"; fi
146
- ;;
147
- rm) exit 0 ;;
148
107
  *) echo "unexpected docker command: $*" >&2; exit 10 ;;
149
108
  esac
150
109
  `);
151
110
  await writeExecutable(join(fakeBin, 'curl'), `#!/usr/bin/env bash
152
111
  set -euo pipefail
153
112
  url="\${!#}"
154
- if [[ "\${FAIL_PUBLIC_ENDPOINT:-0}" == 1 && "$url" == *:9099/* ]]; then exit 26; fi
113
+ if [[ "\${FAIL_HEALTH:-}" == frontend && "$url" == *:5173/* ]]; then exit 26; fi
114
+ if [[ "\${FAIL_HEALTH:-}" == backend && "$url" == *:3000/* ]]; then exit 27; fi
155
115
  if [[ "$url" == */version ]]; then printf '%s' "\${EXPECTED_VERSION:?}"; else printf 'ok'; fi
156
116
  `);
157
- await writeExecutable(join(fakeBin, 'nginx'), `#!/usr/bin/env bash
158
- set -euo pipefail
159
- printf 'nginx %s\n' "$*" >> "$COMMAND_LOG"
160
- if [[ "\${1:-}" == -t ]]; then [[ "\${FAIL_NGINX_TEST:-0}" != 1 ]] || exit 23; exit 0; fi
161
- if [[ "\${1:-}" == -T ]]; then
162
- [[ ! -f "$SERVER_ROOT/nginx/site.conf" ]] || cat "$SERVER_ROOT/nginx/site.conf"
163
- [[ -z "\${EXTRA_NGINX_CONFIG:-}" ]] || printf '%s\n' "$EXTRA_NGINX_CONFIG"
164
- exit 0
165
- fi
166
- if [[ "\${1:-}" == -s && "\${2:-}" == reload && "\${FAIL_NGINX_RELOAD_ONCE:-0}" == 1 && ! -f "$NGINX_FAIL_MARKER" ]]; then
167
- : > "$NGINX_FAIL_MARKER"; exit 24
168
- fi
169
- `);
170
- await writeExecutable(join(fakeBin, 'flock'), '#!/usr/bin/env bash\nexit 0\n');
171
- await writeExecutable(join(fakeBin, 'install'), `#!/usr/bin/env bash
172
- set -euo pipefail
173
- target="\${!#}"
174
- if [[ "\${FAIL_STATE_INSTALL:-0}" == 1 && "$target" == */state/active.env ]]; then exit 25; fi
175
- exec /usr/bin/install "$@"
176
- `);
177
-
178
- return { root, fakeBin, serverProjectsRoot, commandLog, mode };
117
+ return { root, fakeBin, commandLog };
179
118
  }
180
119
 
181
- async function writeExecutable(path, content) {
182
- await writeFile(path, content);
183
- await chmod(path, 0o755);
184
- }
185
-
186
- function run(root, args, fixture, extraEnv = {}) {
120
+ function run(fixture, args, { input, extraEnv = {}, cwd = fixture.root } = {}) {
187
121
  return spawnSync('bash', args, {
188
- cwd: root,
122
+ cwd,
189
123
  encoding: 'utf8',
190
- env: {
191
- ...process.env,
192
- PATH: `${fixture.fakeBin}:${process.env.PATH}`,
193
- COMMAND_LOG: fixture.commandLog,
194
- ...extraEnv,
195
- },
124
+ input,
125
+ env: { ...process.env, PATH: `${fixture.fakeBin}:${process.env.PATH}`, COMMAND_LOG: fixture.commandLog, ...extraEnv },
196
126
  });
197
127
  }
198
128
 
199
129
  function runBuild(fixture, extraEnv = {}, ...extraArgs) {
200
- return run(fixture.root, ['deploy.sh', 'build', ...extraArgs], fixture, extraEnv);
130
+ return run(fixture, ['deploy.sh', 'build', ...extraArgs], { extraEnv });
201
131
  }
202
132
 
203
133
  async function releaseVersion(root) {
@@ -208,30 +138,44 @@ function runValidator(root, version) {
208
138
  return spawnSync(process.execPath, [
209
139
  validator,
210
140
  join(root, 'deploy/deploy.sh'),
211
- join(root, `deploy/${version}/nginx/site.conf`),
212
- join(root, `deploy/${version}/docker/docker-compose.blue-green.yml`),
141
+ join(root, `deploy/${version}/docker/docker-compose.yml`),
213
142
  join(root, 'deploy'),
214
143
  ], { encoding: 'utf8' });
215
144
  }
216
145
 
217
146
  async function installOnServer(fixture) {
218
- const serverRoot = join(fixture.serverProjectsRoot, 'fixture');
147
+ const serverRoot = await temporary('wdyy-server-root-');
219
148
  await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
220
149
  await chmod(join(serverRoot, 'deploy.sh'), 0o755);
221
150
  return serverRoot;
222
151
  }
223
152
 
224
- function runServer(fixture, serverRoot, command, extraEnv = {}, argument) {
225
- const args = [join(serverRoot, 'deploy.sh'), command];
226
- if (argument) args.push(argument);
227
- return run(fixture.root, args, fixture, {
228
- SERVER_ROOT: serverRoot,
229
- NGINX_FAIL_MARKER: join(fixture.root, 'nginx-failed-once'),
230
- ...extraEnv,
153
+ function runServer(fixture, serverRoot, choice, extraEnv = {}, args = []) {
154
+ return run(fixture, [join(serverRoot, 'deploy.sh'), ...args], {
155
+ cwd: fixture.root,
156
+ input: `${choice}\n`,
157
+ extraEnv,
231
158
  });
232
159
  }
233
160
 
234
- test('build 生成根 .env、双镜像和四服务完整包', async () => {
161
+ async function rewriteManifest(versionRoot) {
162
+ const files = [];
163
+ async function walk(current) {
164
+ for (const entry of await readdir(current, { withFileTypes: true })) {
165
+ const path = join(current, entry.name);
166
+ if (entry.isDirectory()) await walk(path);
167
+ else if (entry.isFile() && entry.name !== 'manifest.sha256') files.push(path);
168
+ }
169
+ }
170
+ await walk(versionRoot);
171
+ const lines = await Promise.all(files.sort().map(async (path) => {
172
+ const relativePath = path.slice(versionRoot.length + 1).split('\\').join('/');
173
+ return `${createHash('sha256').update(await readFile(path)).digest('hex')} ${relativePath}`;
174
+ }));
175
+ await writeFile(join(versionRoot, 'manifest.sha256'), `${lines.join('\n')}\n`);
176
+ }
177
+
178
+ test('build 导出带版本号双镜像和双服务完整包', async () => {
235
179
  const fixture = await createFixture();
236
180
  const result = runBuild(fixture);
237
181
  assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
@@ -243,64 +187,27 @@ test('build 生成根 .env、双镜像和四服务完整包', async () => {
243
187
  ]);
244
188
  const validation = runValidator(fixture.root, version);
245
189
  assert.equal(validation.status, 0, validation.stderr);
246
- assert.match(validation.stdout, /valid dual-image deployment package/);
190
+ assert.match(validation.stdout, /valid direct dual-image deployment package/);
247
191
  const log = await readFile(fixture.commandLog, 'utf8');
248
192
  assert.match(log, new RegExp(`--tag fixture_frontend:${version}`));
249
193
  assert.match(log, new RegExp(`--tag fixture_backend:${version}`));
250
- assert.match(log, /--build-arg BACKEND_PORT=3000/);
251
- assert.doesNotMatch(await readFile(join(fixture.root, `deploy/${version}/docker/docker-compose.blue-green.yml`), 'utf8'), /FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|_HEALTH_URL|_VERSION_URL/);
252
- });
253
-
254
- test('none 模式不伪造数据库文件,manual 模式只打包人工脚本', async () => {
255
- const none = await createFixture();
256
- assert.equal(runBuild(none).status, 0);
257
- const noneVersion = await releaseVersion(none.root);
258
- await assert.rejects(stat(join(none.root, `deploy/${noneVersion}/database`)));
259
-
260
- const manual = await createFixture({ mode: 'manual' });
261
- const built = runBuild(manual);
262
- assert.equal(built.status, 0, built.stderr);
263
- assert.match(built.stdout, /migration was not executed/);
264
- const manualVersion = await releaseVersion(manual.root);
265
- assert.ok((await stat(join(manual.root, `deploy/${manualVersion}/database/apply-migrations.sh`))).mode & 0o111);
266
- assert.doesNotMatch(await readFile(join(manual.root, 'deploy/deploy.sh'), 'utf8'), /\bpsql\b/);
194
+ const compose = await readFile(join(fixture.root, `deploy/${version}/docker/docker-compose.yml`), 'utf8');
195
+ assert.deepEqual([...compose.matchAll(/^ ([a-z-]+):$/gm)].map((match) => match[1]), ['frontend', 'backend']);
196
+ assert.doesNotMatch(compose, /blue|green|PROJECT_HTTP_PORT|nginx\/site/);
267
197
  });
268
198
 
269
- test('连续构建的 deploy.sh、Compose 和 Nginx 配置字节一致', async () => {
199
+ test('连续构建保持 deploy.sh 与 Compose 字节一致', async () => {
270
200
  const fixture = await createFixture();
271
201
  assert.equal(runBuild(fixture).status, 0);
272
- const firstVersion = await releaseVersion(fixture.root);
273
- const first = await configurationHashes(fixture.root, firstVersion);
202
+ const first = await releaseVersion(fixture.root);
203
+ const hashes = await Promise.all(['deploy/deploy.sh', `deploy/${first}/docker/docker-compose.yml`].map(async (path) => createHash('sha256').update(await readFile(join(fixture.root, path))).digest('hex')));
274
204
  assert.equal(runBuild(fixture).status, 0);
275
- const secondVersion = await releaseVersion(fixture.root);
276
- const second = await configurationHashes(fixture.root, secondVersion);
277
- assert.notEqual(firstVersion, secondVersion);
278
- assert.deepEqual(second, first);
205
+ const second = await releaseVersion(fixture.root);
206
+ assert.notEqual(second, first);
207
+ const nextHashes = await Promise.all(['deploy/deploy.sh', `deploy/${second}/docker/docker-compose.yml`].map(async (path) => createHash('sha256').update(await readFile(join(fixture.root, path))).digest('hex')));
208
+ assert.deepEqual(nextHashes, hashes);
279
209
  });
280
210
 
281
- async function configurationHashes(root, version) {
282
- const paths = ['deploy/deploy.sh', `deploy/${version}/docker/docker-compose.blue-green.yml`, `deploy/${version}/nginx/site.conf`];
283
- return Promise.all(paths.map(async (path) => createHash('sha256').update(await readFile(join(root, path))).digest('hex')));
284
- }
285
-
286
- async function rewriteManifest(versionRoot, directory = versionRoot) {
287
- const files = [];
288
- async function walk(current) {
289
- for (const entry of await readdir(current, { withFileTypes: true })) {
290
- const path = join(current, entry.name);
291
- if (entry.isDirectory()) await walk(path);
292
- else if (entry.isFile() && entry.name !== 'manifest.sha256') files.push(path);
293
- }
294
- }
295
- await walk(directory);
296
- const lines = await Promise.all(files.sort().map(async (path) => {
297
- const relative = path.slice(versionRoot.length + 1).split('\\').join('/');
298
- const hash = createHash('sha256').update(await readFile(path)).digest('hex');
299
- return `${hash} ${relative}`;
300
- }));
301
- await writeFile(join(versionRoot, 'manifest.sha256'), `${lines.join('\n')}\n`);
302
- }
303
-
304
211
  test('失败构建保留上一发布包和发布账本', async () => {
305
212
  const fixture = await createFixture();
306
213
  assert.equal(runBuild(fixture).status, 0);
@@ -312,257 +219,148 @@ test('失败构建保留上一发布包和发布账本', async () => {
312
219
  assert.equal(await readFile(join(fixture.root, 'AGENTS.md'), 'utf8'), agents);
313
220
  });
314
221
 
315
- test('dotenv 被作为数据解析,不执行命令替换或覆盖脚本 PATH', async () => {
316
- const fixture = await createFixture({ extraEnv: 'UNUSED_VALUE=$(touch should-not-exist)\nPATH=/malicious\n' });
317
- const result = runBuild(fixture);
318
- assert.equal(result.status, 0, result.stderr);
319
- await assert.rejects(access(join(fixture.root, 'should-not-exist')));
320
- });
321
-
322
- test('build 接受带标签基础镜像并拒绝重复键和额外版本参数', async () => {
323
- const duplicate = await createFixture({ extraEnv: 'PROJECT_NAME=duplicate\n' });
324
- assert.notEqual(runBuild(duplicate).status, 0);
325
- const tagged = await createFixture();
326
- assert.equal(runBuild(tagged).status, 0);
222
+ test('build 拒绝额外参数、旧部署键和日志目录配置', async () => {
327
223
  const extra = await createFixture();
328
224
  assert.equal(runBuild(extra, {}, '20260808-001').status, 2);
329
- const logDirectory = await createFixture({ extraEnv: 'LOG_DIR=/tmp/logs\n' });
330
- const rejected = runBuild(logDirectory);
331
- assert.notEqual(rejected.status, 0);
332
- assert.match(rejected.stderr, /must not configure log directories/);
333
- const legacy = await createFixture({ extraEnv: 'FRONTEND_CONTAINER_PORT=8080\n' });
334
- const legacyRejected = runBuild(legacy);
335
- assert.notEqual(legacyRejected.status, 0);
336
- assert.match(legacyRejected.stderr, /legacy duplicate environment key/);
337
- const reverseInclude = await createFixture();
338
- await writeFile(join(reverseInclude.root, '.dockerignore'), `${await readFile(join(reverseInclude.root, '.dockerignore'), 'utf8')}!.env\n`);
339
- const reverseRejected = runBuild(reverseInclude);
340
- assert.notEqual(reverseRejected.status, 0);
341
- assert.match(reverseRejected.stderr, /forbidden reverse include/);
225
+ for (const setting of ['PROJECT_HTTP_PORT=9099\n', 'FRONTEND_BLUE_PORT=19091\n', 'LOG_DIR=/tmp/logs\n', 'DATABASE_MIGRATION_MODE=none\n']) {
226
+ const fixture = await createFixture({ extraEnv: setting });
227
+ const result = runBuild(fixture);
228
+ assert.notEqual(result.status, 0);
229
+ assert.match(result.stderr, /removed deployment configuration/);
230
+ }
342
231
  });
343
232
 
344
- test('validator 拒绝缺失前端镜像、镜像 fallback 和嵌套日志', async () => {
233
+ test('validator 拒绝镜像 fallback、嵌套日志和符号链接', async () => {
345
234
  const fixture = await createFixture();
346
235
  assert.equal(runBuild(fixture).status, 0);
347
- const version = await releaseVersion(fixture.root);
348
- await unlink(join(fixture.root, `deploy/${version}/frontend-image.tar`));
349
- assert.notEqual(runValidator(fixture.root, version).status, 0);
350
-
351
- assert.equal(runBuild(fixture).status, 0);
352
- const next = await releaseVersion(fixture.root);
353
- const composePath = join(fixture.root, `deploy/${next}/docker/docker-compose.blue-green.yml`);
236
+ let version = await releaseVersion(fixture.root);
237
+ let composePath = join(fixture.root, `deploy/${version}/docker/docker-compose.yml`);
354
238
  await writeFile(composePath, (await readFile(composePath, 'utf8')).replace('${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}', '${FRONTEND_IMAGE:-fallback}').replace('../../logs:/app/logs', '../../logs/frontend:/app/logs'));
355
- const validation = runValidator(fixture.root, next);
239
+ let validation = runValidator(fixture.root, version);
356
240
  assert.notEqual(validation.status, 0);
357
241
  assert.match(validation.stderr, /fallback|logs/);
358
- });
359
242
 
360
- test('validator 拒绝版本目录符号链接', async () => {
361
- const fixture = await createFixture();
362
243
  assert.equal(runBuild(fixture).status, 0);
363
- const version = await releaseVersion(fixture.root);
244
+ version = await releaseVersion(fixture.root);
364
245
  await symlink('../outside', join(fixture.root, `deploy/${version}/linked-file`));
365
- const validation = runValidator(fixture.root, version);
246
+ validation = runValidator(fixture.root, version);
366
247
  assert.notEqual(validation.status, 0);
367
248
  assert.match(validation.stderr, /symbolic links/);
368
249
  });
369
250
 
370
- test('服务器拒绝标签不匹配的镜像归档且不执行 docker load', async () => {
251
+ test('菜单可独立运行或替换前端且记录成功日志', async () => {
371
252
  const fixture = await createFixture();
372
253
  assert.equal(runBuild(fixture).status, 0);
373
254
  const version = await releaseVersion(fixture.root);
374
- const versionRoot = join(fixture.root, `deploy/${version}`);
375
- const badArchiveRoot = await temporary('bad-image-');
376
- await writeFile(join(badArchiveRoot, 'manifest.json'), `[{"Config":"config.json","RepoTags":["fixture_frontend:${version}","other_frontend:bad"],"Layers":[]}]\n`);
377
- await writeFile(join(badArchiveRoot, 'config.json'), '{}\n');
378
- const tarResult = spawnSync('/usr/bin/tar', ['-cf', join(versionRoot, 'frontend-image.tar'), '-C', badArchiveRoot, 'manifest.json', 'config.json'], { encoding: 'utf8' });
379
- assert.equal(tarResult.status, 0, tarResult.stderr);
380
- await rewriteManifest(versionRoot);
381
255
  const serverRoot = await installOnServer(fixture);
382
256
  await writeFile(fixture.commandLog, '');
383
- const started = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version });
384
- assert.notEqual(started.status, 0);
385
- assert.match(started.stderr, /exactly one expected tag/);
386
- assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load/);
387
- });
388
-
389
- test('start、replace 和 rollback 成对切换前后端', async () => {
390
- const fixture = await createFixture();
391
- assert.equal(runBuild(fixture).status, 0);
392
- const first = await releaseVersion(fixture.root);
393
- const serverRoot = await installOnServer(fixture);
394
- const started = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: first });
395
- assert.equal(started.status, 0, started.stderr);
396
- assert.match(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), new RegExp(`ACTIVE_COLOR=blue[\\s\\S]*${first}`));
397
-
398
- assert.equal(runBuild(fixture).status, 0);
399
- const second = await releaseVersion(fixture.root);
400
- await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
401
- const replaced = runServer(fixture, serverRoot, 'replace', { EXPECTED_VERSION: second });
402
- assert.equal(replaced.status, 0, replaced.stderr);
403
- assert.match(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), /ACTIVE_COLOR=green/);
404
- assert.match(await readFile(join(serverRoot, 'state/previous.env'), 'utf8'), new RegExp(first));
405
- const upstream = await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8');
406
- assert.match(upstream, /fixture_frontend_active/);
407
- assert.match(upstream, /fixture_backend_active/);
408
-
409
- const rolledBack = runServer(fixture, serverRoot, 'rollback', { EXPECTED_VERSION: first }, first);
410
- assert.equal(rolledBack.status, 0, rolledBack.stderr);
411
- assert.match(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), new RegExp(first));
257
+ const result = runServer(fixture, serverRoot, '1', { EXPECTED_VERSION: version });
258
+ assert.equal(result.status, 0, result.stderr);
259
+ const commands = await readFile(fixture.commandLog, 'utf8');
260
+ assert.match(commands, /docker load -i .*frontend-image\.tar/);
261
+ assert.doesNotMatch(commands, /docker load -i .*backend-image\.tar/);
262
+ assert.match(commands, /compose .* up -d --force-recreate --no-deps frontend/);
263
+ assert.doesNotMatch(commands, /--no-deps backend/);
264
+ assert.match(await readFile(join(serverRoot, 'logs/deploy.log'), 'utf8'), new RegExp(`version=${version} operation=run-or-replace-frontend result=success`));
412
265
  });
413
266
 
414
- test('restart、stop、status 只操作当前颜色的一对容器', async () => {
267
+ test('菜单可独立运行后端、同时运行前后端并同时停止', async () => {
415
268
  const fixture = await createFixture();
416
269
  assert.equal(runBuild(fixture).status, 0);
417
270
  const version = await releaseVersion(fixture.root);
418
271
  const serverRoot = await installOnServer(fixture);
419
- assert.equal(runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version }).status, 0);
272
+
420
273
  await writeFile(fixture.commandLog, '');
421
- assert.equal(runServer(fixture, serverRoot, 'restart', { EXPECTED_VERSION: version }).status, 0);
422
- assert.equal(runServer(fixture, serverRoot, 'stop', { EXPECTED_VERSION: version }).status, 0);
423
- const statusResult = runServer(fixture, serverRoot, 'status', { EXPECTED_VERSION: version });
424
- assert.equal(statusResult.status, 0, statusResult.stderr);
425
- assert.match(statusResult.stdout, new RegExp(version));
426
- const log = await readFile(fixture.commandLog, 'utf8');
427
- assert.match(log, /up -d --force-recreate frontend-blue backend-blue/);
428
- assert.match(log, /stop frontend-blue backend-blue/);
429
- });
274
+ assert.equal(runServer(fixture, serverRoot, '2', { EXPECTED_VERSION: version }).status, 0);
275
+ let commands = await readFile(fixture.commandLog, 'utf8');
276
+ assert.match(commands, /docker load -i .*backend-image\.tar/);
277
+ assert.doesNotMatch(commands, /docker load -i .*frontend-image\.tar/);
278
+ assert.match(commands, /--no-deps backend/);
430
279
 
431
- test('remove 只删除本项目容器、网络和两个镜像仓库并保留日志', async () => {
432
- const fixture = await createFixture();
433
- assert.equal(runBuild(fixture).status, 0);
434
- const serverRoot = await installOnServer(fixture);
435
- await mkdir(join(serverRoot, 'logs'), { recursive: true });
436
- await writeFile(join(serverRoot, 'logs/backend.log'), 'keep\n');
437
280
  await writeFile(fixture.commandLog, '');
438
- const removed = runServer(fixture, serverRoot, 'remove');
439
- assert.equal(removed.status, 0, removed.stderr);
440
- const log = await readFile(fixture.commandLog, 'utf8');
441
- assert.match(log, /docker rm -f c111 c222/);
442
- assert.match(log, /docker network rm n111/);
443
- assert.match(log, /docker image rm -f fixture_backend:20260808-001 fixture_frontend:20260808-001|docker image rm -f fixture_frontend:20260808-001 fixture_backend:20260808-001/);
444
- assert.doesNotMatch(log, /other_project_frontend:shared/);
445
- assert.equal(await readFile(join(serverRoot, 'logs/backend.log'), 'utf8'), 'keep\n');
446
- await access(join(serverRoot, 'release.env'));
447
- });
281
+ assert.equal(runServer(fixture, serverRoot, '3', { EXPECTED_VERSION: version }).status, 0);
282
+ commands = await readFile(fixture.commandLog, 'utf8');
283
+ assert.match(commands, /up -d --force-recreate frontend backend/);
448
284
 
449
- test('端口冲突在加载镜像或启动容器前失败', async () => {
450
- const fixture = await createFixture();
451
- assert.equal(runBuild(fixture).status, 0);
452
- const version = await releaseVersion(fixture.root);
453
- const serverRoot = await installOnServer(fixture);
454
- await mkdir(join(fixture.serverProjectsRoot, 'other/nginx'), { recursive: true });
455
- await writeFile(join(fixture.serverProjectsRoot, 'other/nginx/site.conf'), '# wdyy-project: other\n# wdyy-port: 9099\nserver { listen 9099; }\n');
456
285
  await writeFile(fixture.commandLog, '');
457
- const result = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version });
458
- assert.notEqual(result.status, 0);
459
- assert.match(result.stderr, /already used by another project/);
460
- const log = await readFile(fixture.commandLog, 'utf8');
461
- assert.doesNotMatch(log, /docker load|docker compose/);
286
+ assert.equal(runServer(fixture, serverRoot, '4').status, 0);
287
+ commands = await readFile(fixture.commandLog, 'utf8');
288
+ assert.match(commands, /stop frontend backend/);
289
+ const operationLog = await readFile(join(serverRoot, 'logs/deploy.log'), 'utf8');
290
+ assert.match(operationLog, /operation=run-or-replace-backend result=success/);
291
+ assert.match(operationLog, /operation=run-or-replace-both result=success/);
292
+ assert.match(operationLog, /operation=stop-both result=success/);
462
293
  });
463
294
 
464
- test('展开的全局 Nginx 中存在非标准项目端口时也拒绝部署', async () => {
295
+ test('健康检查失败返回非零并记录失败,不自动回滚', async () => {
465
296
  const fixture = await createFixture();
466
297
  assert.equal(runBuild(fixture).status, 0);
467
298
  const version = await releaseVersion(fixture.root);
468
299
  const serverRoot = await installOnServer(fixture);
469
- await writeFile(fixture.commandLog, '');
470
- const result = runServer(fixture, serverRoot, 'start', {
471
- EXPECTED_VERSION: version,
472
- EXTRA_NGINX_CONFIG: 'server {\n listen 9099;\n}',
473
- });
300
+ const result = runServer(fixture, serverRoot, '2', { EXPECTED_VERSION: version, FAIL_HEALTH: 'backend' });
474
301
  assert.notEqual(result.status, 0);
475
- assert.match(result.stderr, /expanded Nginx configuration/);
476
- assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load|docker compose/);
302
+ const commands = await readFile(fixture.commandLog, 'utf8');
303
+ assert.match(commands, /up -d --force-recreate --no-deps backend/);
304
+ assert.doesNotMatch(commands, /docker .*rollback|nginx -(?:t|s)/);
305
+ assert.match(await readFile(join(serverRoot, 'logs/deploy.log'), 'utf8'), /operation=run-or-replace-backend result=failure/);
477
306
  });
478
307
 
479
- test('其他项目预留任一 Docker blue-green 宿主机端口时在启动前失败', async () => {
308
+ test('非法菜单输入和额外命令参数不执行 Docker 状态变更', async () => {
480
309
  const fixture = await createFixture();
481
310
  assert.equal(runBuild(fixture).status, 0);
482
- const version = await releaseVersion(fixture.root);
483
311
  const serverRoot = await installOnServer(fixture);
484
- await mkdir(join(fixture.serverProjectsRoot, 'other/nginx'), { recursive: true });
485
- await writeFile(
486
- join(fixture.serverProjectsRoot, 'other/nginx/site.conf'),
487
- '# wdyy-project: other\n# wdyy-port: 9100\n# wdyy-docker-ports: 19091,29092,29093,29094\nserver { listen 9100; }\n',
488
- );
489
312
  await writeFile(fixture.commandLog, '');
490
- const result = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version });
491
- assert.notEqual(result.status, 0);
492
- assert.match(result.stderr, /Docker host port 19091/);
493
- assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load|docker compose/);
313
+ const invalid = runServer(fixture, serverRoot, '9');
314
+ assert.notEqual(invalid.status, 0);
315
+ assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker (?:load|compose)/);
316
+ const extra = runServer(fixture, serverRoot, '1', {}, ['start']);
317
+ assert.equal(extra.status, 2);
494
318
  });
495
319
 
496
- test('其他运行中容器发布项目 Docker 端口时在镜像加载前失败', async () => {
320
+ test('服务器拒绝标签不匹配的镜像归档且不执行 docker load', async () => {
497
321
  const fixture = await createFixture();
498
322
  assert.equal(runBuild(fixture).status, 0);
499
323
  const version = await releaseVersion(fixture.root);
324
+ const versionRoot = join(fixture.root, `deploy/${version}`);
325
+ const badArchiveRoot = await temporary('bad-image-');
326
+ await writeFile(join(badArchiveRoot, 'manifest.json'), `[{"Config":"config.json","RepoTags":["fixture_frontend:${version}","other_frontend:bad"],"Layers":[]}]\n`);
327
+ await writeFile(join(badArchiveRoot, 'config.json'), '{}\n');
328
+ assert.equal(spawnSync('/usr/bin/tar', ['-cf', join(versionRoot, 'frontend-image.tar'), '-C', badArchiveRoot, 'manifest.json', 'config.json']).status, 0);
329
+ await rewriteManifest(versionRoot);
500
330
  const serverRoot = await installOnServer(fixture);
501
331
  await writeFile(fixture.commandLog, '');
502
- const result = runServer(fixture, serverRoot, 'start', {
503
- EXPECTED_VERSION: version,
504
- DOCKER_RUNNING_PORTS: 'other_project|0.0.0.0:19093->3000/tcp, [::]:19093->3000/tcp',
505
- });
332
+ const result = runServer(fixture, serverRoot, '1', { EXPECTED_VERSION: version });
506
333
  assert.notEqual(result.status, 0);
507
- assert.match(result.stderr, /Docker host port 19093 is already published by another container/);
508
- assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load|docker compose/);
509
- });
510
-
511
- test('replace 的 Nginx reload 失败时恢复旧 upstream 和状态', async () => {
512
- const fixture = await createFixture();
513
- assert.equal(runBuild(fixture).status, 0);
514
- const first = await releaseVersion(fixture.root);
515
- const serverRoot = await installOnServer(fixture);
516
- assert.equal(runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: first }).status, 0);
517
- const oldUpstream = await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8');
518
- const oldState = await readFile(join(serverRoot, 'state/active.env'), 'utf8');
519
- assert.equal(runBuild(fixture).status, 0);
520
- const second = await releaseVersion(fixture.root);
521
- await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
522
- const failed = runServer(fixture, serverRoot, 'replace', { EXPECTED_VERSION: second, FAIL_NGINX_RELOAD_ONCE: '1' });
523
- assert.notEqual(failed.status, 0);
524
- assert.equal(await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8'), oldUpstream);
525
- assert.equal(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), oldState);
334
+ assert.match(result.stderr, /exactly one expected tag/);
335
+ assert.doesNotMatch(await readFile(fixture.commandLog, 'utf8'), /docker load/);
526
336
  });
527
337
 
528
- test('首次切流后的外部端口验证失败时不提交状态并移除候选 Nginx 文件', async () => {
338
+ test('服务器部署根由脚本物理路径确定', async () => {
529
339
  const fixture = await createFixture();
530
340
  assert.equal(runBuild(fixture).status, 0);
531
341
  const version = await releaseVersion(fixture.root);
532
342
  const serverRoot = await installOnServer(fixture);
533
- const failed = runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: version, FAIL_PUBLIC_ENDPOINT: '1' });
534
- assert.notEqual(failed.status, 0);
535
- assert.match(failed.stderr, /public endpoint verification failed/);
536
- await assert.rejects(access(join(serverRoot, 'state/active.env')));
537
- await assert.rejects(access(join(serverRoot, 'nginx/site.conf')));
538
- await assert.rejects(access(join(serverRoot, 'nginx/active-upstreams.conf')));
343
+ const unrelated = await temporary('unrelated-cwd-');
344
+ const result = run(fixture, [join(serverRoot, 'deploy.sh')], { cwd: unrelated, input: '1\n', extraEnv: { EXPECTED_VERSION: version } });
345
+ assert.equal(result.status, 0, result.stderr);
346
+ await access(join(serverRoot, 'logs/deploy.log'));
347
+ await assert.rejects(access(join(unrelated, 'logs/deploy.log')));
539
348
  });
540
349
 
541
- test('状态提交失败时恢复旧流量和旧状态', async () => {
350
+ test('validator 拒绝缺失镜像和宿主机 Nginx 文件', async () => {
542
351
  const fixture = await createFixture();
543
352
  assert.equal(runBuild(fixture).status, 0);
544
- const first = await releaseVersion(fixture.root);
545
- const serverRoot = await installOnServer(fixture);
546
- assert.equal(runServer(fixture, serverRoot, 'start', { EXPECTED_VERSION: first }).status, 0);
547
- const oldUpstream = await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8');
548
- const oldState = await readFile(join(serverRoot, 'state/active.env'), 'utf8');
549
- assert.equal(runBuild(fixture).status, 0);
550
- const second = await releaseVersion(fixture.root);
551
- await cp(join(fixture.root, 'deploy'), serverRoot, { recursive: true, force: true });
552
- const failed = runServer(fixture, serverRoot, 'replace', { EXPECTED_VERSION: second, FAIL_STATE_INSTALL: '1' });
553
- assert.notEqual(failed.status, 0);
554
- assert.equal(await readFile(join(serverRoot, 'nginx/active-upstreams.conf'), 'utf8'), oldUpstream);
555
- assert.equal(await readFile(join(serverRoot, 'state/active.env'), 'utf8'), oldState);
556
- });
353
+ let version = await releaseVersion(fixture.root);
354
+ await unlink(join(fixture.root, `deploy/${version}/frontend-image.tar`));
355
+ assert.notEqual(runValidator(fixture.root, version).status, 0);
557
356
 
558
- test('服务器命令拒绝额外参数和错误部署根', async () => {
559
- const fixture = await createFixture();
560
357
  assert.equal(runBuild(fixture).status, 0);
561
- const serverRoot = await installOnServer(fixture);
562
- assert.equal(runServer(fixture, serverRoot, 'start', {}, 'unexpected').status, 2);
563
- const wrongRoot = join(fixture.root, 'wrong');
564
- await cp(join(fixture.root, 'deploy'), wrongRoot, { recursive: true });
565
- const wrong = runServer(fixture, wrongRoot, 'status');
566
- assert.notEqual(wrong.status, 0);
567
- assert.match(wrong.stderr, /server deploy root must be exactly/);
358
+ version = await releaseVersion(fixture.root);
359
+ const nginxDir = join(fixture.root, `deploy/${version}/nginx`);
360
+ await mkdir(nginxDir);
361
+ await writeFile(join(nginxDir, 'site.conf'), 'server {}\n');
362
+ await rewriteManifest(join(fixture.root, `deploy/${version}`));
363
+ const validation = runValidator(fixture.root, version);
364
+ assert.notEqual(validation.status, 0);
365
+ assert.match(validation.stderr, /must not contain host Nginx/);
568
366
  });