@wdyy/skills 0.1.13 → 0.1.15

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.
@@ -7,8 +7,10 @@ import { tmpdir } from 'node:os';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { promisify } from 'node:util';
10
+ import { gunzip } from 'node:zlib';
10
11
 
11
12
  const run = promisify(execFile);
13
+ const unzip = promisify(gunzip);
12
14
  const scriptsRoot = dirname(fileURLToPath(import.meta.url));
13
15
  const generator = join(scriptsRoot, 'generate-deployment-files.mjs');
14
16
  const validator = join(scriptsRoot, 'validate-deployment-package.mjs');
@@ -29,11 +31,15 @@ DB_NAME=app
29
31
  DB_SCHEMA=public
30
32
  # API
31
33
  API_URL=
34
+ API_PREFIX=api
35
+ VITE_API_BASE_URL=/api
32
36
  # 部署
33
37
  PROJECT_NAME=example_project
34
38
  DOCKER_BIND_IP=0.0.0.0
39
+ DOCKER_PLATFORM=linux/amd64
35
40
  FRONTEND_BASE_IMAGE=nginx:stable
36
- BACKEND_BASE_IMAGE=node:24-alpine3.24
41
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim
42
+ TZ=Asia/Shanghai
37
43
  `;
38
44
  const exampleText = `# 前端
39
45
  FRONTEND_URL=0.0.0.0
@@ -50,11 +56,15 @@ DB_NAME=
50
56
  DB_SCHEMA=
51
57
  # API
52
58
  API_URL=
59
+ API_PREFIX=api
60
+ VITE_API_BASE_URL=/api
53
61
  # 部署
54
62
  PROJECT_NAME=
55
63
  DOCKER_BIND_IP=
64
+ DOCKER_PLATFORM=linux/amd64
56
65
  FRONTEND_BASE_IMAGE=nginx:stable
57
- BACKEND_BASE_IMAGE=node:24-alpine3.24
66
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim
67
+ TZ=Asia/Shanghai
58
68
  `;
59
69
 
60
70
  const fakeDocker = `#!/usr/bin/env node
@@ -64,9 +74,15 @@ const path = require('path');
64
74
  const cp = require('child_process');
65
75
  const args = process.argv.slice(2);
66
76
  if (process.env.DOCKER_LOG) fs.appendFileSync(process.env.DOCKER_LOG, args.join(' ') + '\\n');
77
+ if (args[0] === 'version') {
78
+ if (process.env.FAIL_SERVER_PLATFORM_READ === '1') process.exit(1);
79
+ process.stdout.write((process.env.SERVER_PLATFORM || 'linux/amd64') + '\\n');
80
+ process.exit(0);
81
+ }
67
82
  if (args[0] === 'image' && args[1] === 'inspect') {
68
83
  const image = args[args.length - 1];
69
84
  if (process.env.MISSING_IMAGE === image) process.exit(1);
85
+ if (args.includes('--format')) process.stdout.write((process.env.IMAGE_PLATFORM || 'linux/amd64') + '\\n');
70
86
  process.exit(0);
71
87
  }
72
88
  if (args[0] === 'build') {
@@ -75,12 +91,37 @@ if (args[0] === 'build') {
75
91
  if (process.env.FAIL_BUILD && tag.includes(process.env.FAIL_BUILD)) process.exit(1);
76
92
  process.exit(0);
77
93
  }
94
+ if (args[0] === 'run') {
95
+ const image = args[args.indexOf('-c') - 1];
96
+ if (process.env.FAIL_IMAGE_TIME && image.includes(process.env.FAIL_IMAGE_TIME)) process.exit(1);
97
+ process.stdout.write((process.env.IMAGE_TIME || '19 CST +0800') + '\\n');
98
+ process.exit(0);
99
+ }
100
+ if (args[0] === 'exec') {
101
+ const name = args[1];
102
+ if (process.env.FAIL_CONTAINER_TIME && name.includes(process.env.FAIL_CONTAINER_TIME)) process.exit(1);
103
+ process.stdout.write((process.env.CONTAINER_TIME || '19 CST +0800') + '\\n');
104
+ process.exit(0);
105
+ }
106
+ if (args[0] === 'container' && args[1] === 'ls') {
107
+ const filter = args[args.indexOf('--filter') + 1] || '';
108
+ const name = filter.replace('name=^/', '').replace('$$', '').replace('$', '');
109
+ if (process.env.EXISTING_CONTAINER_NAME === name) process.stdout.write(name + '\\n');
110
+ process.exit(0);
111
+ }
112
+ if (args[0] === 'container' && args[1] === 'inspect') {
113
+ const name = args[args.length - 1];
114
+ if (process.env.EXISTING_CONTAINER_NAME !== name) process.exit(1);
115
+ process.stdout.write((process.env.CONTAINER_PROJECT || '') + '|' + (process.env.CONTAINER_SERVICE || '') + '\\n');
116
+ process.exit(0);
117
+ }
78
118
  if (args[0] === 'save') {
79
119
  const output = args[args.indexOf('-o') + 1];
80
120
  const tag = args[args.length - 1];
121
+ const [imageOs, imageArchitecture] = (process.env.IMAGE_PLATFORM || 'linux/amd64').split('/');
81
122
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-image-'));
82
123
  fs.writeFileSync(path.join(temporary, 'manifest.json'), JSON.stringify([{Config:'config.json',RepoTags:[tag],Layers:[]}]) + '\\n');
83
- fs.writeFileSync(path.join(temporary, 'config.json'), '{}\\n');
124
+ fs.writeFileSync(path.join(temporary, 'config.json'), JSON.stringify({architecture:imageArchitecture,os:imageOs}) + '\\n');
84
125
  const result = cp.spawnSync('tar', ['-cf', output, '-C', temporary, 'manifest.json', 'config.json']);
85
126
  fs.rmSync(temporary, {recursive:true, force:true});
86
127
  process.exit(result.status || 0);
@@ -88,6 +129,9 @@ if (args[0] === 'save') {
88
129
  if (args[0] === 'compose' && args.includes('up') && process.env.FAIL_COMPOSE_UP === '1') process.exit(1);
89
130
  process.exit(0);
90
131
  `;
132
+ const fakeUname = `#!/usr/bin/env node
133
+ process.stdout.write((process.env.HOST_KERNEL || 'Linux') + '\\n');
134
+ `;
91
135
 
92
136
  async function fixture() {
93
137
  const root = await mkdtemp(join(tmpdir(), 'wdyy-unified-delivery-'));
@@ -104,7 +148,9 @@ async function fixture() {
104
148
  const bin = join(root, 'fake-bin');
105
149
  await mkdir(bin);
106
150
  await writeFile(join(bin, 'docker'), fakeDocker, { mode: 0o755 });
151
+ await writeFile(join(bin, 'uname'), fakeUname, { mode: 0o755 });
107
152
  await chmod(join(bin, 'docker'), 0o755);
153
+ await chmod(join(bin, 'uname'), 0o755);
108
154
  const log = join(root, 'docker.log');
109
155
  const environment = { ...process.env, PATH: `${bin}:${process.env.PATH}`, DOCKER_LOG: log };
110
156
  return { root, log, environment, archive: join(root, 'deploy/example_project-docker.tar.gz') };
@@ -131,6 +177,34 @@ async function rewriteManifest(root) {
131
177
  await writeFile(join(root, 'manifest.sha256'), `${lines.join('\n')}\n`);
132
178
  }
133
179
 
180
+ async function setTargetPlatform(root, platform) {
181
+ const environmentPath = join(root, '.env');
182
+ const environment = await readFile(environmentPath, 'utf8');
183
+ await writeFile(environmentPath, environment.replace(/^DOCKER_PLATFORM=.*$/m, `DOCKER_PLATFORM=${platform}`), { mode: 0o600 });
184
+ }
185
+
186
+ async function readImageArchivePlatform(archive) {
187
+ const { stdout: manifestText } = await run('tar', ['-xOf', archive, 'manifest.json']);
188
+ const [{ Config: configName }] = JSON.parse(manifestText);
189
+ const { stdout: configText } = await run('tar', ['-xOf', archive, configName]);
190
+ const config = JSON.parse(configText);
191
+ return `${config.os}/${config.architecture}`;
192
+ }
193
+
194
+ async function rewriteImageArchivePlatform(archive, platform) {
195
+ const temporary = await mkdtemp(join(tmpdir(), 'wdyy-image-platform-'));
196
+ created.push(temporary);
197
+ await run('tar', ['-xf', archive, '-C', temporary]);
198
+ const manifest = JSON.parse(await readFile(join(temporary, 'manifest.json'), 'utf8'));
199
+ const configName = manifest[0].Config;
200
+ const config = JSON.parse(await readFile(join(temporary, configName), 'utf8'));
201
+ const [os, architecture] = platform.split('/');
202
+ config.os = os;
203
+ config.architecture = architecture;
204
+ await writeFile(join(temporary, configName), `${JSON.stringify(config)}\n`);
205
+ await run('tar', ['-cf', archive, '-C', temporary, 'manifest.json', configName]);
206
+ }
207
+
134
208
  test.after(async () => {
135
209
  await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
136
210
  });
@@ -140,19 +214,64 @@ test('build 使用本地基础镜像并生成一个含双镜像归档的压缩
140
214
  const result = await buildProject(item);
141
215
  assert.match(result.stdout, /delivery archive created/);
142
216
  assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
217
+ const uncompressed = await unzip(await readFile(item.archive));
218
+ assert.equal(uncompressed.includes(Buffer.from('./._.env')), false);
219
+ const listing = await run('tar', ['-tzf', item.archive]);
220
+ assert.doesNotMatch(listing.stdout, /(^|\/)\._[^/]*(?:\n|$)/m);
221
+ assert.doesNotMatch(listing.stderr, /LIBARCHIVE\.xattr|unknown extended header/i);
143
222
  const extracted = await extractArchive(item);
144
223
  assert.deepEqual((await readdir(extracted)).sort(), ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar', 'manifest.sha256'].sort());
145
224
  const validation = await run(process.execPath, [validator, extracted]);
146
225
  assert.match(validation.stdout, /valid unified Docker delivery package/);
226
+ const compose = await readFile(join(extracted, 'docker-compose.yml'), 'utf8');
227
+ assert.equal((compose.match(/TZ: "\$\{TZ:\?TZ is required\}"/g) ?? []).length, 2);
228
+ assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_frontend"/);
229
+ assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_backend"/);
230
+ assert.equal((compose.match(/restart: unless-stopped/g) ?? []).length, 2);
147
231
  const log = await readFile(item.log, 'utf8');
148
232
  assert.match(log, /image inspect nginx:stable/);
149
- assert.match(log, /image inspect node:24-alpine3\.24/);
150
- assert.equal((log.match(/build --pull=false/g) ?? []).length, 2);
233
+ assert.match(log, /image inspect node:24-bookworm-slim/);
234
+ assert.equal((log.match(/build --pull=false --platform linux\/amd64/g) ?? []).length, 2);
235
+ assert.match(log, /build --pull=false --platform linux\/amd64 .*--build-arg VITE_API_BASE_URL=\/api .*example_project_frontend:latest/);
236
+ assert.equal((log.match(/^run --rm --pull=never --platform linux\/amd64 /gm) ?? []).length, 2);
151
237
  assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
152
238
  assert.match(log, /save -o .*frontend-image\.tar example_project_frontend:latest/);
153
239
  assert.match(log, /save -o .*backend-image\.tar example_project_backend:latest/);
154
240
  });
155
241
 
242
+ test('基础镜像平台不匹配时在 build 前失败', async () => {
243
+ const item = await fixture();
244
+ await assert.rejects(buildProject(item, { IMAGE_PLATFORM: 'linux/arm64' }), /image platform mismatch for nginx:stable: expected linux\/amd64, got linux\/arm64/);
245
+ const log = await readFile(item.log, 'utf8');
246
+ assert.doesNotMatch(log, /^build /m);
247
+ assert.equal((await readdir(join(item.root, 'deploy'))).length, 0);
248
+ });
249
+
250
+ test('macOS arm64 与 Windows WSL2 使用 Linux engine 生成并部署单架构交付包', async () => {
251
+ const item = await fixture();
252
+ await setTargetPlatform(item.root, 'linux/arm64');
253
+ await buildProject(item, { HOST_KERNEL: 'Darwin', IMAGE_PLATFORM: 'linux/arm64' });
254
+ const buildLog = await readFile(item.log, 'utf8');
255
+ assert.equal((buildLog.match(/build --pull=false --platform linux\/arm64/g) ?? []).length, 2);
256
+ const extracted = await extractArchive(item);
257
+ assert.equal(await readImageArchivePlatform(join(extracted, 'frontend-image.tar')), 'linux/arm64');
258
+ assert.equal(await readImageArchivePlatform(join(extracted, 'backend-image.tar')), 'linux/arm64');
259
+ const validation = await run(process.execPath, [validator, extracted]);
260
+ assert.match(validation.stdout, /valid unified Docker delivery package/);
261
+ await writeFile(item.log, '');
262
+ await run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, HOST_KERNEL: 'Linux', WSL_DISTRO_NAME: 'Ubuntu', IMAGE_PLATFORM: 'linux/arm64', SERVER_PLATFORM: 'linux/arm64' } });
263
+ const deployLog = await readFile(item.log, 'utf8');
264
+ assert.match(deployLog, /^version --format \{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}$/m);
265
+ assert.equal((deployLog.match(/^load -i /gm) ?? []).length, 2);
266
+ assert.equal((deployLog.match(/^exec example_project_(?:frontend|backend) /gm) ?? []).length, 2);
267
+ });
268
+
269
+ test('原生 Windows shell 在任何 Docker 操作前被拒绝', async () => {
270
+ const item = await fixture();
271
+ await assert.rejects(buildProject(item, { HOST_KERNEL: 'Windows_NT' }), /unsupported execution host: Windows_NT/);
272
+ await assert.rejects(stat(item.log), { code: 'ENOENT' });
273
+ });
274
+
156
275
  test('缺少本地基础镜像时在 build 前失败', async () => {
157
276
  const item = await fixture();
158
277
  await assert.rejects(buildProject(item, { MISSING_IMAGE: 'nginx:stable' }), /required local frontend base image is missing/);
@@ -172,6 +291,20 @@ test('构建失败保留上一份成功交付包且不暴露半成品', async ()
172
291
  assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
173
292
  });
174
293
 
294
+ test('镜像时间探测失败时保留上一份成功交付包', async () => {
295
+ const item = await fixture();
296
+ await buildProject(item);
297
+ const before = createHash('sha256').update(await readFile(item.archive)).digest('hex');
298
+ await assert.rejects(buildProject(item, { FAIL_IMAGE_TIME: 'frontend' }), /unable to verify image CST time: example_project_frontend:latest/);
299
+ const after = createHash('sha256').update(await readFile(item.archive)).digest('hex');
300
+ assert.equal(after, before);
301
+ const log = await readFile(item.log, 'utf8');
302
+ const lastSave = log.lastIndexOf('save -o ');
303
+ const lastRun = log.lastIndexOf('run --rm --pull=never');
304
+ assert(lastRun > lastSave);
305
+ assert.doesNotMatch(log.slice(lastRun), /^save -o /m);
306
+ });
307
+
175
308
  test('服务器无参数执行时从脚本目录整体加载并部署前后端', async () => {
176
309
  const item = await fixture();
177
310
  await buildProject(item);
@@ -179,9 +312,68 @@ test('服务器无参数执行时从脚本目录整体加载并部署前后端',
179
312
  await writeFile(item.log, '');
180
313
  await run(join(extracted, 'deploy.sh'), [], { cwd: tmpdir(), env: item.environment });
181
314
  const log = await readFile(item.log, 'utf8');
315
+ const versionIndex = log.indexOf('version --format {{.Server.Os}}/{{.Server.Arch}}');
316
+ const firstLoadIndex = log.indexOf('load -i ');
317
+ const ownershipIndex = log.indexOf('container ls --all --filter name=^/example_project_frontend$');
318
+ assert(versionIndex >= 0 && ownershipIndex > versionIndex && firstLoadIndex > ownershipIndex);
182
319
  assert.equal((log.match(/^load -i /gm) ?? []).length, 2);
183
320
  assert.match(log, /compose --env-file .* --project-name example_project -f .* up -d --force-recreate --no-build --pull never --wait frontend backend/);
184
- assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=success/);
321
+ assert.equal((log.match(/^exec example_project_(?:frontend|backend) /gm) ?? []).length, 2);
322
+ assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /CST \+0800 operation=deploy result=success/);
323
+ });
324
+
325
+ test('服务器平台无法读取、不受支持或不匹配时在加载镜像前失败', async () => {
326
+ const item = await fixture();
327
+ await buildProject(item);
328
+ const cases = [
329
+ [{ FAIL_SERVER_PLATFORM_READ: '1' }, /Docker Server platform is unavailable/],
330
+ [{ SERVER_PLATFORM: 'windows/amd64' }, /Windows container mode is unsupported; switch Docker Desktop to Linux containers/],
331
+ [{ SERVER_PLATFORM: 'linux/arm64' }, /Docker Server platform mismatch: expected linux\/amd64, got linux\/arm64/],
332
+ ];
333
+ for (const [extraEnvironment, expectedError] of cases) {
334
+ const extracted = await extractArchive(item);
335
+ await writeFile(item.log, '');
336
+ await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, ...extraEnvironment } }), expectedError);
337
+ const log = await readFile(item.log, 'utf8');
338
+ assert.match(log, /^version --format \{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}$/m);
339
+ assert.doesNotMatch(log, /^load -i /m);
340
+ assert.doesNotMatch(log, /^compose .* (?:up|stop) /m);
341
+ assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
342
+ }
343
+ });
344
+
345
+ test('固定容器名冲突在镜像加载前失败,当前项目所有权允许继续', async () => {
346
+ const item = await fixture();
347
+ await buildProject(item);
348
+ const conflictPackage = await extractArchive(item);
349
+ await writeFile(item.log, '');
350
+ await assert.rejects(run(join(conflictPackage, 'deploy.sh'), [], {
351
+ env: { ...item.environment, EXISTING_CONTAINER_NAME: 'example_project_frontend', CONTAINER_PROJECT: 'other_project', CONTAINER_SERVICE: 'frontend' }
352
+ }), /container name conflict: example_project_frontend belongs to other_project\|frontend/);
353
+ const conflictLog = await readFile(item.log, 'utf8');
354
+ assert.match(conflictLog, /^container inspect .*example_project_frontend$/m);
355
+ assert.doesNotMatch(conflictLog, /^load -i /m);
356
+
357
+ const ownedPackage = await extractArchive(item);
358
+ await writeFile(item.log, '');
359
+ await run(join(ownedPackage, 'deploy.sh'), [], {
360
+ env: { ...item.environment, EXISTING_CONTAINER_NAME: 'example_project_frontend', CONTAINER_PROJECT: 'example_project', CONTAINER_SERVICE: 'frontend' }
361
+ });
362
+ assert.equal(((await readFile(item.log, 'utf8')).match(/^load -i /gm) ?? []).length, 2);
363
+ });
364
+
365
+ test('服务器在 Docker 操作前拒绝 AppleDouble 和上传压缩包残留', async () => {
366
+ const item = await fixture();
367
+ await buildProject(item);
368
+ const extracted = await extractArchive(item);
369
+ await writeFile(item.log, '');
370
+ await writeFile(join(extracted, '._deploy.sh'), 'AppleDouble\n');
371
+ await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: item.environment }), /unexpected package entry: \._deploy\.sh/);
372
+ assert.equal(await readFile(item.log, 'utf8'), '');
373
+ await rm(join(extracted, '._deploy.sh'));
374
+ await writeFile(join(extracted, 'example_project-docker.tar.gz'), 'uploaded archive\n');
375
+ await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: item.environment }), /unexpected package entry: example_project-docker\.tar\.gz/);
376
+ assert.equal(await readFile(item.log, 'utf8'), '');
185
377
  });
186
378
 
187
379
  test('stop 与 status 始终作用于完整项目栈,非法参数不触发 Docker', async () => {
@@ -194,6 +386,7 @@ test('stop 与 status 始终作用于完整项目栈,非法参数不触发 Doc
194
386
  const beforeInvalid = await readFile(item.log, 'utf8');
195
387
  assert.match(beforeInvalid, /stop frontend backend/);
196
388
  assert.match(beforeInvalid, /ps frontend backend/);
389
+ assert.doesNotMatch(beforeInvalid, /^\s*(?:start|restart)\b|^compose .* up /m);
197
390
  await assert.rejects(run(join(extracted, 'deploy.sh'), ['frontend'], { env: item.environment }), /usage:/);
198
391
  assert.equal(await readFile(item.log, 'utf8'), beforeInvalid);
199
392
  assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=stop result=success/);
@@ -207,6 +400,19 @@ test('整栈健康等待失败返回非零并记录失败', async () => {
207
400
  assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
208
401
  });
209
402
 
403
+ test('运行容器时间探测失败在健康等待后明确失败并记录容器', async () => {
404
+ const item = await fixture();
405
+ await buildProject(item);
406
+ const extracted = await extractArchive(item);
407
+ await writeFile(item.log, '');
408
+ await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, FAIL_CONTAINER_TIME: 'backend' } }), /unable to verify running container CST time: example_project_backend/);
409
+ const log = await readFile(item.log, 'utf8');
410
+ const composeIndex = log.indexOf(' up -d --force-recreate');
411
+ const execIndex = log.indexOf('exec example_project_backend');
412
+ assert(composeIndex >= 0 && execIndex > composeIndex);
413
+ assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
414
+ });
415
+
210
416
  test('validator 拒绝数据库容器和不完整远程数据库配置', async () => {
211
417
  const item = await fixture();
212
418
  await buildProject(item);
@@ -221,6 +427,72 @@ test('validator 拒绝数据库容器和不完整远程数据库配置', async (
221
427
  await assert.rejects(run(process.execPath, [validator, envPackage]), /Root \.env is missing DB_URL/);
222
428
  });
223
429
 
430
+ test('缺失或非 Asia/Shanghai TZ 在 Docker 状态变化前被拒绝', async () => {
431
+ const item = await fixture();
432
+ const environmentPath = join(item.root, '.env');
433
+ await writeFile(environmentPath, envText.replace('TZ=Asia/Shanghai', 'TZ=../Shanghai'), { mode: 0o600 });
434
+ await assert.rejects(buildProject(item), /TZ must equal Asia\/Shanghai for CST \(UTC\+8\)/);
435
+ await assert.rejects(stat(item.log), { code: 'ENOENT' });
436
+
437
+ await writeFile(environmentPath, envText, { mode: 0o600 });
438
+ await buildProject(item);
439
+
440
+ const missingPackage = await extractArchive(item);
441
+ await writeFile(join(missingPackage, '.env'), envText.replace('TZ=Asia/Shanghai\n', ''), { mode: 0o600 });
442
+ await rewriteManifest(missingPackage);
443
+ await assert.rejects(run(process.execPath, [validator, missingPackage]), /Root \.env is missing TZ/);
444
+
445
+ const invalidPackage = await extractArchive(item);
446
+ await writeFile(join(invalidPackage, '.env'), envText.replace('TZ=Asia/Shanghai', 'TZ=UTC'), { mode: 0o600 });
447
+ await rewriteManifest(invalidPackage);
448
+ await assert.rejects(run(process.execPath, [validator, invalidPackage]), /Invalid TZ; expected Asia\/Shanghai/);
449
+
450
+ const composePackage = await extractArchive(item);
451
+ const compose = await readFile(join(composePackage, 'docker-compose.yml'), 'utf8');
452
+ await writeFile(join(composePackage, 'docker-compose.yml'), compose.replace(' TZ: "${TZ:?TZ is required}"\n', ''));
453
+ await rewriteManifest(composePackage);
454
+ await assert.rejects(run(process.execPath, [validator, composePackage]), /Both services must explicitly use the same required TZ/);
455
+ });
456
+
457
+ test('validator 拒绝容器名、重启策略或端口单一来源被破坏', async () => {
458
+ const item = await fixture();
459
+ await buildProject(item);
460
+
461
+ const namePackage = await extractArchive(item);
462
+ let compose = await readFile(join(namePackage, 'docker-compose.yml'), 'utf8');
463
+ await writeFile(join(namePackage, 'docker-compose.yml'), compose.replace('container_name: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend"', 'container_name: unrelated_frontend'));
464
+ await rewriteManifest(namePackage);
465
+ await assert.rejects(run(process.execPath, [validator, namePackage]), /frontend container name must equal its image repository/);
466
+
467
+ const restartPackage = await extractArchive(item);
468
+ compose = await readFile(join(restartPackage, 'docker-compose.yml'), 'utf8');
469
+ await writeFile(join(restartPackage, 'docker-compose.yml'), compose.replace('restart: unless-stopped', 'restart: always'));
470
+ await rewriteManifest(restartPackage);
471
+ await assert.rejects(run(process.execPath, [validator, restartPackage]), /Both services must use restart: unless-stopped/);
472
+
473
+ const portPackage = await extractArchive(item);
474
+ compose = await readFile(join(portPackage, 'docker-compose.yml'), 'utf8');
475
+ await writeFile(join(portPackage, 'docker-compose.yml'), compose.replace('${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}', '${DOCKER_BIND_IP}:${FRONTEND_PORT}:80'));
476
+ await rewriteManifest(portPackage);
477
+ await assert.rejects(run(process.execPath, [validator, portPackage]), /Frontend port mapping is invalid/);
478
+ });
479
+
480
+ test('validator 拒绝冲突的 API 路径和缺失的代理注入', async () => {
481
+ const item = await fixture();
482
+ await buildProject(item);
483
+
484
+ const envPackage = await extractArchive(item);
485
+ await writeFile(join(envPackage, '.env'), envText.replace('VITE_API_BASE_URL=/api', 'VITE_API_BASE_URL=/service'), { mode: 0o600 });
486
+ await rewriteManifest(envPackage);
487
+ await assert.rejects(run(process.execPath, [validator, envPackage]), /VITE_API_BASE_URL must equal \/API_PREFIX/);
488
+
489
+ const composePackage = await extractArchive(item);
490
+ const compose = await readFile(join(composePackage, 'docker-compose.yml'), 'utf8');
491
+ await writeFile(join(composePackage, 'docker-compose.yml'), compose.replace(' VITE_API_BASE_URL: "${VITE_API_BASE_URL:?VITE_API_BASE_URL is required}"\n', ''));
492
+ await rewriteManifest(composePackage);
493
+ await assert.rejects(run(process.execPath, [validator, composePackage]), /Frontend API path is not passed to Nginx/);
494
+ });
495
+
224
496
  test('validator 拒绝清单篡改和额外包内容', async () => {
225
497
  const item = await fixture();
226
498
  await buildProject(item);
@@ -232,6 +504,15 @@ test('validator 拒绝清单篡改和额外包内容', async () => {
232
504
  await assert.rejects(run(process.execPath, [validator, extracted]), /Compose must define only frontend and backend|Checksum mismatch/);
233
505
  });
234
506
 
507
+ test('validator 拒绝镜像归档平台与 DOCKER_PLATFORM 不一致', async () => {
508
+ const item = await fixture();
509
+ await buildProject(item);
510
+ const extracted = await extractArchive(item);
511
+ await rewriteImageArchivePlatform(join(extracted, 'backend-image.tar'), 'linux/arm64');
512
+ await rewriteManifest(extracted);
513
+ await assert.rejects(run(process.execPath, [validator, extracted]), /backend-image\.tar platform must equal DOCKER_PLATFORM/);
514
+ });
515
+
235
516
  test('build 在任何 Docker 操作前拒绝环境文件命令语法', async () => {
236
517
  const item = await fixture();
237
518
  await writeFile(join(item.root, '.env'), envText.replace('DB_PASSWORD=secret', 'DB_PASSWORD=$(id)'), { mode: 0o600 });
@@ -3,7 +3,8 @@ FROM ${BACKEND_BASE_IMAGE} AS build
3
3
  WORKDIR /app
4
4
  COPY . .
5
5
  RUN corepack enable && pnpm install --frozen-lockfile
6
- RUN pnpm --dir src/backend build
6
+ RUN pnpm --filter ./src/backend build
7
+ RUN pnpm --filter ./src/backend deploy --prod --legacy /runtime
7
8
 
8
9
  FROM ${BACKEND_BASE_IMAGE} AS runtime
9
10
  WORKDIR /app
@@ -11,8 +12,6 @@ ARG BACKEND_PORT
11
12
  ENV NODE_ENV=production
12
13
  ENV PORT=${BACKEND_PORT}
13
14
  LABEL org.opencontainers.image.title="wdyy-backend"
14
- COPY --from=build /app/package.json ./package.json
15
- COPY --from=build /app/node_modules ./node_modules
16
- COPY --from=build /app/src/backend/dist ./dist
15
+ COPY --from=build /runtime ./src/backend/
17
16
  EXPOSE ${BACKEND_PORT}
18
- CMD ["node", "dist/main.js"]
17
+ CMD ["node", "src/backend/dist/main.js"]
@@ -53,15 +53,28 @@ validate_env_permissions() {
53
53
  [[ "$mode" == 400 || "$mode" == 600 ]] || fail "root .env permissions must be 400 or 600; got $mode"
54
54
  }
55
55
 
56
+ assert_execution_host() {
57
+ local kernel
58
+ kernel="$(uname -s 2>/dev/null)" || fail "execution host OS is unavailable"
59
+ case "$kernel" in
60
+ Darwin|Linux) ;;
61
+ *) fail "unsupported execution host: $kernel; use Linux, macOS, or Windows Docker Desktop through WSL2" ;;
62
+ esac
63
+ if [[ "$kernel" == Linux ]] && { [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/sys/kernel/osrelease 2>/dev/null; }; then
64
+ [[ ! "$SCRIPT_DIR" =~ ^/mnt/[A-Za-z](/|$) ]] || fail "Windows Docker Desktop execution must use the WSL2 Linux filesystem, not $SCRIPT_DIR"
65
+ fi
66
+ }
67
+
56
68
  reset_configuration() {
57
69
  unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
70
+ unset API_PREFIX VITE_API_BASE_URL
58
71
  unset DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA
59
- unset PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
72
+ unset PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE TZ
60
73
  }
61
74
 
62
75
  is_configuration_key() {
63
76
  case "$1" in
64
- FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE)
77
+ FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|API_PREFIX|VITE_API_BASE_URL|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|DOCKER_PLATFORM|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE|TZ)
65
78
  return 0 ;;
66
79
  *) return 1 ;;
67
80
  esac
@@ -105,23 +118,74 @@ load_dotenv() {
105
118
 
106
119
  validate_configuration() {
107
120
  local key value
108
- for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE; do
121
+ for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT API_PREFIX VITE_API_BASE_URL DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE TZ; do
109
122
  value="${!key:-}"
110
123
  [[ -n "$value" ]] || fail "required configuration is missing: $key"
111
124
  done
112
125
  [[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] || fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
113
126
  [[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
114
127
  [[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
128
+ [[ "$API_PREFIX" =~ ^[A-Za-z][A-Za-z0-9_-]*$ ]] || fail "API_PREFIX must be one safe path segment"
129
+ [[ "$VITE_API_BASE_URL" == "/$API_PREFIX" ]] || fail "VITE_API_BASE_URL must equal /$API_PREFIX"
115
130
  [[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
131
+ [[ "$DOCKER_PLATFORM" =~ ^linux/(amd64|arm64)$ ]] || fail "DOCKER_PLATFORM must be linux/amd64 or linux/arm64"
116
132
  [[ "$DB_URL" =~ ^[A-Za-z0-9._:/?@\&=%-]+$ ]] || fail "DB_URL contains unsafe characters"
117
133
  [[ "$FRONTEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "FRONTEND_BASE_IMAGE is not a valid image reference"
118
134
  [[ "$BACKEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "BACKEND_BASE_IMAGE is not a valid image reference"
135
+ [[ "$TZ" == Asia/Shanghai ]] || fail "TZ must equal Asia/Shanghai for CST (UTC+8)"
119
136
  assert_port FRONTEND_PORT "$FRONTEND_PORT"
120
137
  assert_port BACKEND_PORT "$BACKEND_PORT"
121
138
  assert_port DB_PORT "$DB_PORT"
122
139
  [[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must differ"
123
140
  }
124
141
 
142
+ assert_image_platform() {
143
+ local image="$1" actual
144
+ actual="$(docker image inspect --platform "$DOCKER_PLATFORM" --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null)" || fail "image platform is unavailable for $image: $DOCKER_PLATFORM"
145
+ [[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "image platform mismatch for $image: expected $DOCKER_PLATFORM, got $actual"
146
+ }
147
+
148
+ assert_server_platform() {
149
+ local actual
150
+ actual="$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}' 2>/dev/null)" || fail "Docker Server platform is unavailable"
151
+ [[ -n "$actual" ]] || fail "Docker Server platform is empty"
152
+ [[ "$actual" != windows/* ]] || fail "Windows container mode is unsupported; switch Docker Desktop to Linux containers"
153
+ [[ "$actual" =~ ^linux/(amd64|arm64)$ ]] || fail "unsupported Docker Server platform: $actual"
154
+ [[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "Docker Server platform mismatch: expected $DOCKER_PLATFORM, got $actual"
155
+ }
156
+
157
+ assert_container_ownership() {
158
+ local name="$1" expected_service="$2" matches ownership
159
+ matches="$(docker container ls --all --filter "name=^/${name}$" --format '{{.Names}}' 2>/dev/null)" || fail "unable to inspect existing container name: $name"
160
+ [[ -n "$matches" ]] || return 0
161
+ [[ "$matches" == "$name" ]] || fail "container name lookup returned unexpected results for $name: $matches"
162
+ ownership="$(docker container inspect --format '{{ index .Config.Labels "com.docker.compose.project" }}|{{ index .Config.Labels "com.docker.compose.service" }}' "$name" 2>/dev/null)" || fail "unable to inspect container ownership: $name"
163
+ [[ "$ownership" == "$PROJECT_NAME|$expected_service" ]] || fail "container name conflict: $name belongs to ${ownership:-unknown}; expected $PROJECT_NAME|$expected_service"
164
+ }
165
+
166
+ assert_stack_container_ownership() {
167
+ assert_container_ownership "${PROJECT_NAME}_frontend" frontend
168
+ assert_container_ownership "${PROJECT_NAME}_backend" backend
169
+ }
170
+
171
+ assert_cst_time_value() {
172
+ local label="$1" actual="$2"
173
+ actual="${actual%$'\r'}"
174
+ [[ "$actual" =~ ^([01][0-9]|2[0-3])[[:space:]]CST[[:space:]]\+0800$ ]] || fail "$label time must use 24-hour CST +0800; got ${actual:-empty}"
175
+ }
176
+
177
+ assert_image_cst_time() {
178
+ local image="$1" actual
179
+ actual="$(docker run --rm --pull=never --platform "$DOCKER_PLATFORM" --entrypoint sh --env TZ=Asia/Shanghai "$image" -c 'LC_ALL=C date "+%H %Z %z"' 2>/dev/null)" || fail "unable to verify image CST time: $image"
180
+ assert_cst_time_value "image $image" "$actual"
181
+ }
182
+
183
+ assert_container_cst_time() {
184
+ local name="$1" actual
185
+ actual="$(docker exec "$name" sh -c 'LC_ALL=C date "+%H %Z %z"' 2>/dev/null)" || fail "unable to verify running container CST time: $name"
186
+ assert_cst_time_value "container $name" "$actual"
187
+ }
188
+
125
189
  validate_dockerignore() {
126
190
  local file="$1" pattern line
127
191
  [[ -f "$file" && ! -L "$file" ]] || fail "required Docker ignore file is missing: $file"
@@ -206,8 +270,9 @@ ensure_deploy_output_scope() {
206
270
  build_release() {
207
271
  local project_root="$SCRIPT_DIR" frontend_dockerfile backend_dockerfile compose_source dockerignore_file
208
272
  local frontend_image backend_image package_root deploy_dir archive_name candidate
209
- for command in docker tar install find sort grep sed awk mktemp; do require_command "$command"; done
273
+ for command in docker tar install find sort grep sed awk mktemp uname; do require_command "$command"; done
210
274
  select_checksum_tool
275
+ assert_execution_host
211
276
  load_dotenv "$project_root/.env"
212
277
  validate_configuration
213
278
  frontend_dockerfile="$project_root/src/frontend/Dockerfile"
@@ -220,14 +285,20 @@ build_release() {
220
285
  validate_dockerignore "$dockerignore_file"
221
286
  docker image inspect "$FRONTEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local frontend base image is missing: $FRONTEND_BASE_IMAGE"
222
287
  docker image inspect "$BACKEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local backend base image is missing: $BACKEND_BASE_IMAGE"
288
+ assert_image_platform "$FRONTEND_BASE_IMAGE"
289
+ assert_image_platform "$BACKEND_BASE_IMAGE"
223
290
  frontend_image="${PROJECT_NAME}_frontend:latest"
224
291
  backend_image="${PROJECT_NAME}_backend:latest"
225
292
  acquire_build_lock
226
293
  trap cleanup_build EXIT
227
- docker build --pull=false --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
228
- docker build --pull=false --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
294
+ docker build --pull=false --platform "$DOCKER_PLATFORM" --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "VITE_API_BASE_URL=$VITE_API_BASE_URL" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
295
+ docker build --pull=false --platform "$DOCKER_PLATFORM" --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
229
296
  docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "frontend image build did not create $frontend_image"
230
297
  docker image inspect "$backend_image" >/dev/null 2>&1 || fail "backend image build did not create $backend_image"
298
+ assert_image_platform "$frontend_image"
299
+ assert_image_platform "$backend_image"
300
+ assert_image_cst_time "$frontend_image"
301
+ assert_image_cst_time "$backend_image"
231
302
  STAGING_ROOT="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
232
303
  package_root="$STAGING_ROOT/package"
233
304
  mkdir -p "$package_root"
@@ -245,7 +316,7 @@ build_release() {
245
316
  archive_name="${PROJECT_NAME}-docker.tar.gz"
246
317
  ensure_deploy_output_scope "$deploy_dir" "$archive_name"
247
318
  candidate="$STAGING_ROOT/$archive_name"
248
- tar -czf "$candidate" -C "$package_root" .
319
+ env COPYFILE_DISABLE=1 tar --no-xattrs -czf "$candidate" -C "$package_root" .
249
320
  tar -tzf "$candidate" >/dev/null || fail "generated delivery archive is invalid"
250
321
  mv -f "$candidate" "$deploy_dir/$archive_name"
251
322
  rm -rf -- "$STAGING_ROOT"
@@ -258,8 +329,9 @@ build_release() {
258
329
 
259
330
  initialize_server_context() {
260
331
  DEPLOY_ROOT="$SCRIPT_DIR"
261
- for command in docker tar find sort grep sed awk; do require_command "$command"; done
332
+ for command in docker tar find sort grep sed awk uname; do require_command "$command"; done
262
333
  select_checksum_tool
334
+ assert_execution_host
263
335
  load_dotenv "$DEPLOY_ROOT/.env"
264
336
  validate_configuration
265
337
  [[ -f "$DEPLOY_ROOT/docker-compose.yml" && ! -L "$DEPLOY_ROOT/docker-compose.yml" ]] || fail "docker-compose.yml is missing"
@@ -273,7 +345,7 @@ compose() {
273
345
  record_operation() {
274
346
  local result="$1" timestamp
275
347
  [[ -n "$DEPLOY_ROOT" && -d "$DEPLOY_ROOT/logs" && -n "$OPERATION" ]] || return 0
276
- timestamp="$(date '+%Y-%m-%d %H:%M:%S %z')"
348
+ timestamp="$(TZ=Asia/Shanghai LC_ALL=C date '+%Y-%m-%d %H:%M:%S %Z %z')"
277
349
  printf '%s operation=%s result=%s\n' "$timestamp" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
278
350
  }
279
351
 
@@ -283,11 +355,17 @@ deploy_stack() {
283
355
  verify_manifest "$DEPLOY_ROOT"
284
356
  archive_has_tag "$DEPLOY_ROOT/frontend-image.tar" "$frontend_image"
285
357
  archive_has_tag "$DEPLOY_ROOT/backend-image.tar" "$backend_image"
358
+ assert_server_platform
359
+ assert_stack_container_ownership
286
360
  docker load -i "$DEPLOY_ROOT/frontend-image.tar" >/dev/null
287
361
  docker load -i "$DEPLOY_ROOT/backend-image.tar" >/dev/null
362
+ assert_image_platform "${PROJECT_NAME}_frontend:latest"
363
+ assert_image_platform "${PROJECT_NAME}_backend:latest"
288
364
  docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "loaded frontend image is missing: $frontend_image"
289
365
  docker image inspect "$backend_image" >/dev/null 2>&1 || fail "loaded backend image is missing: $backend_image"
290
366
  compose up -d --force-recreate --no-build --pull never --wait frontend backend
367
+ assert_container_cst_time "${PROJECT_NAME}_frontend"
368
+ assert_container_cst_time "${PROJECT_NAME}_backend"
291
369
  }
292
370
 
293
371
  run_logged_operation() {
@@ -307,6 +385,8 @@ server_deploy() {
307
385
 
308
386
  server_stop() {
309
387
  initialize_server_context
388
+ assert_server_platform
389
+ assert_stack_container_ownership
310
390
  run_logged_operation stop compose stop frontend backend
311
391
  }
312
392