@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.
- package/.well-known/skills/index.json +2 -2
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +33 -44
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +27 -63
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +19 -9
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +16 -2
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +61 -123
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +145 -347
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +184 -675
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +35 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -7
- package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -2
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +2 -2
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +1 -1
- package/README.md +4 -2
- package/lib/wdyy-cli.js +3 -2
- package/package.json +1 -1
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +0 -70
- 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,
|
|
7
|
-
if (!deployPath || !
|
|
8
|
-
throw new Error('Pass deploy.sh,
|
|
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,
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/
|
|
54
|
-
/
|
|
55
|
-
/pnpm
|
|
56
|
-
/pnpm
|
|
57
|
-
/pnpm
|
|
58
|
-
/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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${
|
|
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
|
|
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 !==
|
|
85
|
-
if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !==
|
|
86
|
-
if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !==
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
if (
|
|
90
|
-
if (/container_name:/.test(compose)) throw new Error('
|
|
91
|
-
if (/
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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 (
|
|
129
|
-
for (const key of ['
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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; }
|