@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
@@ -3,21 +3,21 @@ import { createHash } from 'node:crypto';
3
3
  import { lstat, readFile, readdir, stat } from 'node:fs/promises';
4
4
  import { basename, join, relative, resolve, sep } from 'node:path';
5
5
 
6
- const [deployPath, nginxPath, composePath, packageRoot] = process.argv.slice(2);
7
- if (!deployPath || !nginxPath || !composePath || !packageRoot) {
8
- throw new Error('Pass deploy.sh, Nginx template, Compose file and generated deploy directory paths');
6
+ const [deployPath, composePath, packageRoot] = process.argv.slice(2);
7
+ if (!deployPath || !composePath || !packageRoot || process.argv.length !== 5) {
8
+ throw new Error('Pass deploy.sh, Compose file and generated deploy directory paths');
9
9
  }
10
10
 
11
- const [deploy, nginx, compose] = await Promise.all([
12
- readFile(deployPath, 'utf8'),
13
- readFile(nginxPath, 'utf8'),
14
- readFile(composePath, 'utf8'),
15
- ]);
11
+ const [deploy, compose] = await Promise.all([readFile(deployPath, 'utf8'), readFile(composePath, 'utf8')]);
16
12
 
17
13
  function requirePattern(text, pattern, message) {
18
14
  if (!pattern.test(text)) throw new Error(message);
19
15
  }
20
16
 
17
+ function assertDeepEqual(actual, expected, message) {
18
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
19
+ }
20
+
21
21
  function parseDotenv(text) {
22
22
  const values = new Map();
23
23
  for (const [index, raw] of text.replaceAll('\r\n', '\n').split('\n').entries()) {
@@ -26,154 +26,98 @@ function parseDotenv(text) {
26
26
  if (!match) throw new Error(`Invalid root .env line ${index + 1}`);
27
27
  if (values.has(match[1])) throw new Error(`Duplicate root .env key: ${match[1]}`);
28
28
  let value = match[2];
29
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
30
- value = value.slice(1, -1);
31
- } else if (value.startsWith('"') || value.startsWith("'")) {
32
- throw new Error(`Unclosed root .env quote: ${match[1]}`);
33
- }
29
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
30
+ else if (value.startsWith('"') || value.startsWith("'")) throw new Error(`Unclosed root .env quote: ${match[1]}`);
34
31
  values.set(match[1], value);
35
32
  }
36
33
  return values;
37
34
  }
38
35
 
39
- const commands = ['build', 'start', 'replace', 'restart', 'stop', 'remove', 'status', 'rollback'];
40
- for (const command of commands) {
41
- requirePattern(deploy, new RegExp(`^ ${command}\\)`, 'm'), `Missing deployment command: ${command}`);
42
- }
43
- for (const command of commands.filter((item) => item !== 'rollback')) {
44
- requirePattern(
45
- deploy,
46
- new RegExp(`^ ${command}\\) \\[\\[ "\\$#" -eq 1 \\]\\] \\|\\| usage;`, 'm'),
47
- `${command} must reject extra arguments`,
48
- );
49
- }
50
- requirePattern(deploy, /^ rollback\) \[\[ "\$#" -eq 2 \]\] \|\| usage;/m, 'rollback must require exactly one version');
51
-
52
- const requiredDeploySemantics = [
53
- /release_ledger next/,
54
- /pnpm test/,
55
- /pnpm lint/,
56
- /pnpm typecheck/,
57
- /pnpm build/,
58
- /frontend_image="\$\{PROJECT_NAME\}_frontend:\$version"/,
59
- /backend_image="\$\{PROJECT_NAME\}_backend:\$version"/,
60
- /docker save -o "\$version_root\/frontend-image\.tar"/,
61
- /docker save -o "\$version_root\/backend-image\.tar"/,
62
- /load_dotenv "\$DEPLOY_ROOT\/\.env"/,
63
- /endpoint_url "\$DOCKER_BIND_IP"/,
64
- /server deploy root must be exactly/,
65
- /assert_project_ports_isolated/,
66
- /flock -x 9/,
67
- /Nginx reload failed; old project configuration restored/,
68
- /database migration was not executed/,
69
- /docker ps -aq --filter "label=com\.docker\.compose\.project=\$PROJECT_NAME"/,
70
- /\$1 == frontend \|\| \$1 == backend/,
71
- ];
72
- for (const pattern of requiredDeploySemantics) requirePattern(deploy, pattern, `Missing deployment behavior: ${pattern}`);
73
- if (/\bsource\s+["']?[^\n]*(?:\.env|ACTIVE_STATE|PREVIOUS_STATE)/.test(deploy)) {
74
- throw new Error('Environment and state files must be parsed as data, not sourced');
75
- }
36
+ for (const [pattern, message] of [
37
+ [/if \[\[ "\$#" -eq 0 \]\]; then server_menu/, 'No-argument execution must open the server menu'],
38
+ [/elif \[\[ "\$#" -eq 1 && "\$1" == build \]\]; then build_release/, 'build must be the only command-line operation'],
39
+ [/'1\. 运行或替换前端'/, 'Missing frontend menu item'],
40
+ [/'2\. 运行或替换后端'/, 'Missing backend menu item'],
41
+ [/'3\. 运行或替换前后端'/, 'Missing dual-service menu item'],
42
+ [/'4\. 停止'/, 'Missing stop menu item'],
43
+ [/docker save -o "\$version_root\/frontend-image\.tar"/, 'Missing frontend image export'],
44
+ [/docker save -o "\$version_root\/backend-image\.tar"/, 'Missing backend image export'],
45
+ [/frontend_image="\$\{PROJECT_NAME\}_frontend:\$version"/, 'Missing versioned frontend image name'],
46
+ [/backend_image="\$\{PROJECT_NAME\}_backend:\$version"/, 'Missing versioned backend image name'],
47
+ [/compose up -d --force-recreate --no-deps frontend/, 'Frontend operation must only recreate frontend'],
48
+ [/compose up -d --force-recreate --no-deps backend/, 'Backend operation must only recreate backend'],
49
+ [/compose up -d --force-recreate frontend backend/, 'Combined operation must recreate both services'],
50
+ [/compose stop frontend backend/, 'Stop operation must stop both services'],
51
+ [/logs\/deploy\.log/, 'Server operations must write deploy.log'],
52
+ [/pnpm test/, 'Missing test release gate'],
53
+ [/pnpm lint/, 'Missing lint release gate'],
54
+ [/pnpm typecheck/, 'Missing typecheck release gate'],
55
+ [/pnpm build/, 'Missing build release gate'],
56
+ ]) requirePattern(deploy, pattern, message);
57
+
58
+ if (/^\s*(?:start|replace|restart|remove|status|rollback)\)/m.test(deploy)) throw new Error('Legacy server commands are forbidden');
59
+ if (/\bsource\s+["']?[^\n]*\.env/.test(deploy)) throw new Error('Environment files must be parsed as data, not sourced');
76
60
  if (/\beval\b/.test(deploy)) throw new Error('deploy.sh must not use eval');
77
61
  if (/\bpsql\b/.test(deploy)) throw new Error('deploy.sh must not execute database clients');
78
62
  if (/\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy)) throw new Error('deploy.sh must not upload or pull artifacts');
79
- if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${nginx}\n${compose}`)) throw new Error('Fixed /srv deployment roots are forbidden');
63
+ if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${compose}`)) throw new Error('Fixed /srv deployment roots are forbidden');
64
+ if (/nginx\s+-(?:t|s)|active-upstreams|ACTIVE_COLOR|PREVIOUS_COLOR/.test(deploy)) throw new Error('Host Nginx and blue-green state are forbidden');
80
65
 
81
66
  const serviceNames = [...compose.matchAll(/^ ([a-z-]+):$/gm)].map((match) => match[1]);
82
- assertDeepEqual(serviceNames, ['frontend-blue', 'backend-blue', 'frontend-green', 'backend-green'], 'Compose must define exactly four ordered blue-green services');
67
+ assertDeepEqual(serviceNames, ['frontend', 'backend'], 'Compose must define exactly two ordered services');
83
68
  if (compose.includes(':-') || compose.includes('not-configured')) throw new Error('Compose image fallback is forbidden');
84
- if ((compose.match(/image: \$\{FRONTEND_IMAGE:\?FRONTEND_IMAGE is required\}/g) ?? []).length !== 2) throw new Error('Both frontend services must require FRONTEND_IMAGE');
85
- if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !== 2) throw new Error('Both backend services must require BACKEND_IMAGE');
86
- if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !== 4) throw new Error('All four services must use root .env');
87
- const logMounts = compose.match(/^\s*- \.\.\/\.\.\/logs:\/app\/logs\s*$/gm) ?? [];
88
- if (logMounts.length !== 4) throw new Error('All four services must mount flat project-root logs');
89
- if (/LOG_DIR|HOST_LOG_DIR|logs\/(?:frontend|backend|blue|green)/.test(compose)) throw new Error('Configurable or nested log directories are forbidden');
90
- if (/container_name:/.test(compose)) throw new Error('container_name would weaken Compose project isolation');
91
- if (/FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|_HEALTH_URL|_VERSION_URL/.test(compose)) {
92
- throw new Error('Compose contains legacy duplicate URL or port configuration');
93
- }
94
- for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'FRONTEND_URL', 'BACKEND_URL']) {
95
- if (!compose.includes(`\${${key}}`)) throw new Error(`Compose must use ${key}`);
96
- }
97
-
98
- for (const placeholder of ['__PROJECT_NAME__', '__PROJECT_HTTP_PORT__', '__DOCKER_HOST_PORTS__', '__DEPLOY_ROOT__', '__FRONTEND_UPSTREAM_NAME__', '__BACKEND_UPSTREAM_NAME__']) {
99
- if (!nginx.includes(placeholder)) throw new Error(`Nginx template is missing ${placeholder}`);
100
- }
101
- if (!/^# wdyy-project: __PROJECT_NAME__$/m.test(nginx) || !/^# wdyy-port: __PROJECT_HTTP_PORT__$/m.test(nginx) || !/^# wdyy-docker-ports: __DOCKER_HOST_PORTS__$/m.test(nginx)) {
102
- throw new Error('Nginx template must expose deterministic project and all port markers');
103
- }
104
- if (/listen\s+\d+/.test(nginx) || /proxy_pass\s+https?:\/\/(?!__)/.test(nginx)) throw new Error('Nginx URL or port is hardcoded');
69
+ if ((compose.match(/image: \$\{FRONTEND_IMAGE:\?FRONTEND_IMAGE is required\}/g) ?? []).length !== 1) throw new Error('Frontend must require FRONTEND_IMAGE');
70
+ if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !== 1) throw new Error('Backend must require BACKEND_IMAGE');
71
+ if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !== 2) throw new Error('Both services must use root .env');
72
+ if ((compose.match(/^\s*- \.\.\/\.\.\/logs:\/app\/logs\s*$/gm) ?? []).length !== 2) throw new Error('Both services must mount flat project-root logs');
73
+ if (!compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"')) throw new Error('Frontend must directly expose FRONTEND_PORT');
74
+ if (!compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"')) throw new Error('Backend must directly expose BACKEND_PORT');
75
+ if (/LOG_DIR|HOST_LOG_DIR|logs\/(?:frontend|backend|blue|green)|container_name:/.test(compose)) throw new Error('Configurable, nested logs or container_name are forbidden');
76
+ if (/frontend-(?:blue|green)|backend-(?:blue|green)|PROJECT_HTTP_PORT|_(?:BLUE|GREEN)_PORT|nginx\/site/.test(compose)) throw new Error('Blue-green or host Nginx Compose configuration is forbidden');
105
77
 
106
78
  const rootEnvPath = join(packageRoot, '.env');
107
79
  const rootEnvStat = await stat(rootEnvPath);
108
- if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0) {
109
- throw new Error('Root .env must be a private owner-readable file');
110
- }
80
+ if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0) throw new Error('Root .env must be a private owner-readable file');
111
81
  const rootEnvText = await readFile(rootEnvPath, 'utf8');
112
82
  const env = parseDotenv(rootEnvText);
113
83
  const sectionNames = [...rootEnvText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
114
84
  if (sectionNames.join('|') !== '前端|后端|数据库|API|部署') throw new Error('Root .env must use the five standard sections in order');
115
- const requiredEnv = [
116
- 'FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT',
117
- 'PROJECT_NAME', 'SERVER_PROJECTS_ROOT', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
118
- 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
119
- 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE', 'DATABASE_MIGRATION_MODE',
120
- ];
121
- for (const key of requiredEnv) if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
85
+ for (const key of ['FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
86
+ if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
87
+ }
122
88
  for (const key of env.keys()) {
123
- if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
124
- throw new Error(`Root .env contains legacy duplicate configuration: ${key}`);
89
+ if (['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT', 'SERVER_PROJECTS_ROOT', 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR', 'FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE', 'MIGRATION_RUNNER_SOURCE'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
90
+ throw new Error(`Root .env contains removed deployment configuration: ${key}`);
125
91
  }
126
92
  }
127
93
  if (!/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME'))) throw new Error('Invalid PROJECT_NAME');
128
- if (!/^\/.+[^/]$/.test(env.get('SERVER_PROJECTS_ROOT'))) throw new Error('Invalid SERVER_PROJECTS_ROOT');
129
- for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) {
130
- if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
131
- }
132
- const migrationMode = env.get('DATABASE_MIGRATION_MODE');
133
- if (!['manual', 'none'].includes(migrationMode)) throw new Error('DATABASE_MIGRATION_MODE must be manual or none');
134
- if (env.has('LOG_DIR') || env.has('HOST_LOG_DIR')) throw new Error('Root .env must not configure log directories');
135
- const allPortKeys = ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
136
- for (const key of allPortKeys) {
94
+ for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
95
+ for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT']) {
137
96
  const port = Number(env.get(key));
138
97
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${key} must be a valid port`);
139
98
  }
140
- const hostPortKeys = ['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
141
- const hostPorts = hostPortKeys.map((key) => Number(env.get(key)));
142
- if (hostPorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535) || new Set(hostPorts).size !== hostPorts.length) {
143
- throw new Error('Project and host blue-green ports must be valid and unique');
144
- }
99
+ if (env.get('FRONTEND_PORT') === env.get('BACKEND_PORT')) throw new Error('Frontend and backend host ports must differ');
145
100
 
146
101
  const releaseEnv = await readFile(join(packageRoot, 'release.env'), 'utf8');
147
102
  const releaseMatch = releaseEnv.match(/^RELEASE_VERSION=(\d{8}-\d{3})\n$/);
148
103
  if (!releaseMatch) throw new Error('release.env must contain exactly one release version');
149
104
  const version = releaseMatch[1];
150
- const expectedRootEntries = ['.env', version, 'deploy.sh', 'release.env'].sort();
151
- assertDeepEqual((await readdir(packageRoot)).sort(), expectedRootEntries, 'Generated deploy root has unexpected entries');
105
+ assertDeepEqual((await readdir(packageRoot)).sort(), ['.env', version, 'deploy.sh', 'release.env'].sort(), 'Generated deploy root has unexpected entries');
152
106
  if (resolve(deployPath) !== resolve(packageRoot, 'deploy.sh')) throw new Error('deploy.sh must be the package root script');
153
- if (resolve(nginxPath) !== resolve(packageRoot, version, 'nginx/site.conf')) throw new Error('Nginx path must be inside the selected version');
154
- if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.blue-green.yml')) throw new Error('Compose path must be inside the selected version');
107
+ if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.yml')) throw new Error('Compose path must be inside the selected version');
155
108
 
156
109
  const versionRoot = join(packageRoot, version);
157
- const requiredPaths = ['frontend-image.tar', 'backend-image.tar', 'manifest.sha256', 'docker/docker-compose.blue-green.yml', 'nginx/site.conf'];
158
- for (const path of requiredPaths) {
110
+ for (const path of ['frontend-image.tar', 'backend-image.tar', 'manifest.sha256', 'docker/docker-compose.yml']) {
159
111
  const info = await stat(join(versionRoot, path));
160
112
  if (!info.isFile()) throw new Error(`Required release path is not a file: ${path}`);
161
113
  }
162
- if (migrationMode === 'manual') {
163
- const runner = await stat(join(versionRoot, 'database/apply-migrations.sh'));
164
- if (!runner.isFile() || (runner.mode & 0o111) === 0) throw new Error('Manual migration runner must be executable');
165
- const migrations = await readdir(join(versionRoot, 'database/migrations'));
166
- if (!migrations.some((item) => /^V\d{3}__[a-z0-9]+(?:_[a-z0-9]+)*\.sql$/.test(item))) throw new Error('Manual migration files are missing');
167
- } else {
168
- await assertMissing(join(versionRoot, 'database'), 'none mode must not package database files');
169
- }
114
+ await assertMissing(join(versionRoot, 'database'), 'Release package must not contain database migration files');
115
+ await assertMissing(join(versionRoot, 'nginx'), 'Release package must not contain host Nginx files');
170
116
 
171
117
  const versionFiles = (await listFiles(versionRoot)).sort();
172
118
  for (const path of versionFiles) {
173
119
  const name = basename(path);
174
- if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) {
175
- throw new Error(`Secret file must not enter version directory: ${path}`);
176
- }
120
+ if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) throw new Error(`Secret file must not enter version directory: ${path}`);
177
121
  }
178
122
  const manifest = await readFile(join(versionRoot, 'manifest.sha256'), 'utf8');
179
123
  const entries = new Map();
@@ -189,13 +133,7 @@ for (const [path, expected] of entries) {
189
133
  if (actual !== expected) throw new Error(`Checksum mismatch: ${path}`);
190
134
  }
191
135
 
192
- process.stdout.write(`valid dual-image deployment package ${version}\n`);
193
-
194
- function assertDeepEqual(actual, expected, message) {
195
- if (JSON.stringify(actual) !== JSON.stringify(expected)) {
196
- throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
197
- }
198
- }
136
+ process.stdout.write(`valid direct dual-image deployment package ${version}\n`);
199
137
 
200
138
  async function assertMissing(path, message) {
201
139
  try { await lstat(path); } catch (error) { if (error.code === 'ENOENT') return; throw error; }