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