@wdyy/skills 0.1.12 → 0.1.13

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 (19) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +30 -46
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
  4. package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +103 -0
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +138 -155
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +97 -97
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +93 -116
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +185 -311
  9. package/.well-known/skills/wdyy-deployment-standard/templates/{Dockerfile.template → backend.Dockerfile.template} +5 -9
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +210 -256
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +11 -13
  12. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
  13. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -19
  14. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +0 -6
  15. package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +9 -4
  16. package/README.md +2 -2
  17. package/lib/wdyy-cli.js +2 -1
  18. package/package.json +1 -1
  19. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +0 -99
@@ -3,11 +3,11 @@ set -euo pipefail
3
3
 
4
4
  SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
5
5
  SCRIPT_PATH="$SCRIPT_DIR/$(basename -- "${BASH_SOURCE[0]}")"
6
- CHECKSUM_TOOL=
7
6
  BUILD_LOCK=
7
+ STAGING_ROOT=
8
8
  DEPLOY_ROOT=
9
- RELEASE_VERSION=
10
9
  OPERATION=
10
+ CHECKSUM_TOOL=
11
11
 
12
12
  fail() {
13
13
  echo "$*" >&2
@@ -15,8 +15,10 @@ fail() {
15
15
  }
16
16
 
17
17
  usage() {
18
- echo "usage: ./deploy.sh build # local project root" >&2
19
- echo " ./deploy.sh # server project root menu" >&2
18
+ echo "usage: ./deploy.sh build # local project root" >&2
19
+ echo " ./deploy.sh # server: deploy the complete stack" >&2
20
+ echo " ./deploy.sh stop # server: stop the complete stack" >&2
21
+ echo " ./deploy.sh status # server: inspect stack status" >&2
20
22
  exit 2
21
23
  }
22
24
 
@@ -24,8 +26,17 @@ require_command() {
24
26
  command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
25
27
  }
26
28
 
27
- assert_version() {
28
- [[ "${1:-}" =~ ^[0-9]{8}-[0-9]{3}$ ]] || fail "version must match YYYYMMDD-NNN"
29
+ select_checksum_tool() {
30
+ if command -v sha256sum >/dev/null 2>&1; then CHECKSUM_TOOL=sha256sum
31
+ elif command -v shasum >/dev/null 2>&1; then CHECKSUM_TOOL=shasum
32
+ else fail "required SHA-256 command not found"
33
+ fi
34
+ }
35
+
36
+ checksum_value() {
37
+ if [[ "$CHECKSUM_TOOL" == sha256sum ]]; then sha256sum "$1" | awk '{print $1}'
38
+ else shasum -a 256 "$1" | awk '{print $1}'
39
+ fi
29
40
  }
30
41
 
31
42
  assert_port() {
@@ -34,336 +45,279 @@ assert_port() {
34
45
  (( value >= 1 && value <= 65535 )) || fail "$name must be between 1 and 65535"
35
46
  }
36
47
 
48
+ validate_env_permissions() {
49
+ local file="$1" mode
50
+ if stat -c '%a' "$file" >/dev/null 2>&1; then mode="$(stat -c '%a' "$file")"
51
+ else mode="$(stat -f '%Lp' "$file")"
52
+ fi
53
+ [[ "$mode" == 400 || "$mode" == 600 ]] || fail "root .env permissions must be 400 or 600; got $mode"
54
+ }
55
+
37
56
  reset_configuration() {
38
- unset PROJECT_NAME DOCKER_BIND_IP
39
57
  unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
40
- unset FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
41
- unset FRONTEND_DOCKERFILE BACKEND_DOCKERFILE DOCKERIGNORE_FILE
42
- unset DEPLOY_CONFIG_DIR COMPOSE_SOURCE
58
+ 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
43
60
  }
44
61
 
45
- is_deployment_key() {
62
+ is_configuration_key() {
46
63
  case "$1" in
47
- FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|PROJECT_NAME|DOCKER_BIND_IP|\
48
- FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE|FRONTEND_DOCKERFILE|BACKEND_DOCKERFILE|\
49
- DOCKERIGNORE_FILE|DEPLOY_CONFIG_DIR|COMPOSE_SOURCE)
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)
50
65
  return 0 ;;
51
66
  *) return 1 ;;
52
67
  esac
53
68
  }
54
69
 
55
- is_removed_configuration_key() {
70
+ is_removed_key() {
56
71
  case "$1" in
57
- PROJECT_HTTP_PORT|FRONTEND_BLUE_PORT|FRONTEND_GREEN_PORT|BACKEND_BLUE_PORT|BACKEND_GREEN_PORT|\
58
- SERVER_PROJECTS_ROOT|NGINX_SOURCE|LOG_DIR|HOST_LOG_DIR|FRONTEND_CONTAINER_PORT|\
59
- BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|DATABASE_MIGRATION_MODE|MIGRATIONS_SOURCE|\
60
- MIGRATION_RUNNER_SOURCE|*_HEALTH_URL|*_VERSION_URL)
72
+ 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|RELEASE_VERSION|*_HEALTH_URL|*_VERSION_URL)
61
73
  return 0 ;;
62
74
  *) return 1 ;;
63
75
  esac
64
76
  }
65
77
 
66
- validate_env_permissions() {
67
- local file="$1" mode
68
- if stat -c '%a' "$file" >/dev/null 2>&1; then mode="$(stat -c '%a' "$file")"; else mode="$(stat -f '%Lp' "$file")"; fi
69
- [[ "$mode" == 400 || "$mode" == 600 ]] || fail "root .env permissions must be 400 or 600; got $mode"
70
- }
71
-
72
78
  load_dotenv() {
73
- local file="$1" raw line key value first last loaded_key
79
+ local file="$1" raw line key value first last loaded
74
80
  local -a loaded_keys=()
75
- [[ -r "$file" ]] || fail "required root environment file is missing: $file"
81
+ [[ -r "$file" && -f "$file" && ! -L "$file" ]] || fail "required root environment file is missing or unsafe: $file"
76
82
  validate_env_permissions "$file"
77
83
  reset_configuration
78
84
  while IFS= read -r raw || [[ -n "$raw" ]]; do
79
85
  line="${raw%$'\r'}"
80
86
  [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
81
87
  [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || fail "invalid .env line; expected KEY=VALUE"
82
- key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
83
- ! is_removed_configuration_key "$key" || fail "removed deployment configuration is forbidden: $key"
84
- for loaded_key in "${loaded_keys[@]:-}"; do [[ "$loaded_key" != "$key" ]] || fail "duplicate .env key: $key"; done
88
+ key="${BASH_REMATCH[1]}"
89
+ value="${BASH_REMATCH[2]}"
90
+ ! is_removed_key "$key" || fail "removed deployment configuration is forbidden: $key"
91
+ [[ "$value" != *'$('* && "$value" != *'`'* ]] || fail "executable syntax is forbidden in .env: $key"
92
+ for loaded in "${loaded_keys[@]:-}"; do [[ "$loaded" != "$key" ]] || fail "duplicate .env key: $key"; done
85
93
  loaded_keys+=("$key")
86
94
  if [[ ${#value} -ge 2 ]]; then
87
- first="${value:0:1}"; last="${value: -1}"
95
+ first="${value:0:1}"
96
+ last="${value: -1}"
88
97
  if [[ "$first" == "'" || "$first" == '"' ]]; then
89
98
  [[ "$last" == "$first" ]] || fail "unclosed quoted .env value: $key"
90
99
  value="${value:1:${#value}-2}"
91
100
  fi
92
101
  fi
93
- if is_deployment_key "$key"; then printf -v "$key" '%s' "$value"; export "$key"; fi
102
+ if is_configuration_key "$key"; then printf -v "$key" '%s' "$value"; fi
94
103
  done < "$file"
95
104
  }
96
105
 
97
106
  validate_configuration() {
98
- local name
99
- local -a required=(FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT PROJECT_NAME DOCKER_BIND_IP FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE)
100
- for name in "${required[@]}"; do [[ -n "${!name:-}" ]] || fail "$name is required in root .env"; done
107
+ 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
109
+ value="${!key:-}"
110
+ [[ -n "$value" ]] || fail "required configuration is missing: $key"
111
+ done
101
112
  [[ "$PROJECT_NAME" =~ ^[a-z][a-z0-9_]*$ ]] || fail "PROJECT_NAME must match ^[a-z][a-z0-9_]*$"
102
- for name in DOCKER_BIND_IP FRONTEND_URL BACKEND_URL; do [[ "${!name}" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "$name contains unsafe characters"; done
113
+ [[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
114
+ [[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
115
+ [[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
116
+ [[ "$DB_URL" =~ ^[A-Za-z0-9._:/?@\&=%-]+$ ]] || fail "DB_URL contains unsafe characters"
117
+ [[ "$FRONTEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "FRONTEND_BASE_IMAGE is not a valid image reference"
118
+ [[ "$BACKEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "BACKEND_BASE_IMAGE is not a valid image reference"
103
119
  assert_port FRONTEND_PORT "$FRONTEND_PORT"
104
120
  assert_port BACKEND_PORT "$BACKEND_PORT"
105
- [[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must be different"
106
- }
107
-
108
- probe_host() {
109
- case "$1" in
110
- 0.0.0.0) printf '127.0.0.1\n' ;;
111
- ::) printf '[::1]\n' ;;
112
- *:*) printf '[%s]\n' "$1" ;;
113
- *) printf '%s\n' "$1" ;;
114
- esac
121
+ assert_port DB_PORT "$DB_PORT"
122
+ [[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must differ"
115
123
  }
116
124
 
117
- endpoint_url() {
118
- local host
119
- host="$(probe_host "$1")"
120
- printf 'http://%s:%s%s\n' "$host" "$2" "$3"
121
- }
122
-
123
- resolve_project_path() {
124
- local project_root="$1" candidate="$2"
125
- if [[ "$candidate" = /* ]]; then printf '%s\n' "$candidate"; else printf '%s/%s\n' "$project_root" "$candidate"; fi
126
- }
127
-
128
- select_checksum_tool() {
129
- if command -v sha256sum >/dev/null 2>&1; then CHECKSUM_TOOL=sha256sum
130
- elif command -v shasum >/dev/null 2>&1; then CHECKSUM_TOOL=shasum
131
- else fail "sha256sum or shasum is required"
132
- fi
133
- }
134
-
135
- checksum_value() {
136
- if [[ "$CHECKSUM_TOOL" == sha256sum ]]; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi
137
- }
138
-
139
- release_ledger() {
140
- node --input-type=module - "$@" <<'NODE'
141
- import fs from 'node:fs';
142
- const [mode, agentsPath, value, timestamp, outputPath] = process.argv.slice(2);
143
- const heading = '## 发布记录';
144
- const header = '| 版本号 | 构建时间 |';
145
- const separator = '|---|---|';
146
- const rowPattern = /^\| (\d{8}-\d{3}) \| (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}) \|$/;
147
- function fail(message) { throw new Error(message); }
148
- function validDate(text) { const y=Number(text.slice(0,4)),m=Number(text.slice(4,6)),d=Number(text.slice(6,8)),date=new Date(Date.UTC(y,m-1,d)); return date.getUTCFullYear()===y&&date.getUTCMonth()===m-1&&date.getUTCDate()===d; }
149
- function inspect(text) {
150
- const lines=text.replace(/\r\n/g,'\n').split('\n');
151
- const headings=lines.flatMap((line,index)=>line===heading?[index]:[]);
152
- if(headings.length>1) fail('AGENTS.md contains multiple release record sections');
153
- if(!headings.length) return {lines,headingIndex:-1,endIndex:lines.length,versions:[]};
154
- const headingIndex=headings[0]; let endIndex=lines.length;
155
- for(let index=headingIndex+1;index<lines.length;index+=1){if(/^## /.test(lines[index])){endIndex=index;break;}}
156
- const section=lines.slice(headingIndex+1,endIndex); while(section[0]==='')section.shift(); while(section.at(-1)==='')section.pop();
157
- if(section[0]!==header||section[1]!==separator) fail('AGENTS.md release record table is invalid');
158
- const unique=new Set(),versions=[];
159
- for(const row of section.slice(2)){const match=row.match(rowPattern);if(!match||!validDate(match[1].slice(0,8)))fail(`invalid AGENTS.md release record row: ${row}`);if(unique.has(match[1]))fail(`duplicate release version: ${match[1]}`);unique.add(match[1]);versions.push(match[1]);}
160
- return {lines,headingIndex,endIndex,versions};
161
- }
162
- function nextVersion(ledger,date){if(!/^\d{8}$/.test(date)||!validDate(date))fail(`invalid current release date: ${date}`);const max=ledger.versions.filter(item=>item.startsWith(`${date}-`)).reduce((result,item)=>Math.max(result,Number(item.slice(-3))),0);if(max>=999)fail(`release sequence exhausted for ${date}`);return `${date}-${String(max+1).padStart(3,'0')}`;}
163
- const original=fs.readFileSync(agentsPath,'utf8'); const ledger=inspect(original);
164
- if(mode==='next') process.stdout.write(`${nextVersion(ledger,value)}\n`);
165
- else if(mode==='prepare'){
166
- if(!/^\d{8}-\d{3}$/.test(value)||!validDate(value.slice(0,8)))fail(`invalid release version: ${value}`);
167
- if(!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{2}:\d{2}$/.test(timestamp))fail('invalid build time');
168
- const expected=nextVersion(ledger,value.slice(0,8));if(value!==expected)fail(`release ledger changed: expected ${expected}, got ${value}`);
169
- const row=`| ${value} | ${timestamp} |`;let updated;
170
- if(ledger.headingIndex===-1){const gap=original.length===0?'':original.endsWith('\n')?'\n':'\n\n';updated=`${original}${gap}${heading}\n\n${header}\n${separator}\n${row}\n`;}
171
- else{const lines=[...ledger.lines];let insertAt=ledger.endIndex;while(insertAt>ledger.headingIndex+1&&lines[insertAt-1]==='')insertAt-=1;lines.splice(insertAt,0,row);updated=lines.join('\n');}
172
- fs.writeFileSync(outputPath,updated,{mode:fs.statSync(agentsPath).mode});
173
- } else fail(`unknown release ledger mode: ${mode}`);
174
- NODE
125
+ validate_dockerignore() {
126
+ local file="$1" pattern line
127
+ [[ -f "$file" && ! -L "$file" ]] || fail "required Docker ignore file is missing: $file"
128
+ for pattern in '.env' '.env.*' '*.pem' '*.key' id_rsa id_ed25519 credentials 'credentials.*' 'deploy/' 'logs/' '.git/' 'node_modules/' 'venv/' '.pnpm-store/' '.deploy-build.lock/' '.deploy-build.*/'; do
129
+ grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"
130
+ done
131
+ while IFS= read -r line || [[ -n "$line" ]]; do
132
+ [[ "$line" != !* || "$line" == '!.env.example' ]] || fail "Docker ignore file contains forbidden reverse include: $line"
133
+ done < "$file"
175
134
  }
176
135
 
177
136
  create_manifest() {
178
- local version_root="$1" file relative checksum
179
- (cd "$version_root"; find . -type f ! -name manifest.sha256 -print | LC_ALL=C sort | while IFS= read -r file; do relative="${file#./}"; checksum="$(checksum_value "$file")"; printf '%s %s\n' "$checksum" "$relative"; done > manifest.sha256)
137
+ local root="$1" file checksum
138
+ : > "$root/manifest.sha256"
139
+ for file in .env backend-image.tar deploy.sh docker-compose.yml frontend-image.tar; do
140
+ checksum="$(checksum_value "$root/$file")"
141
+ printf '%s %s\n' "$checksum" "$file" >> "$root/manifest.sha256"
142
+ done
180
143
  }
181
144
 
182
145
  verify_manifest() {
183
- local root="$1" line checksum relative actual
184
- [[ -s "$root/manifest.sha256" ]] || fail "missing or empty manifest.sha256"
146
+ local root="$1" line checksum relative actual count=0
147
+ [[ -s "$root/manifest.sha256" && ! -L "$root/manifest.sha256" ]] || fail "missing or unsafe manifest.sha256"
185
148
  while IFS= read -r line; do
186
- checksum="${line%% *}"; relative="${line#* }"
187
- [[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != ../* && "$relative" != *'/../'* ]] || fail "invalid manifest entry"
149
+ checksum="${line%% *}"
150
+ relative="${line#* }"
151
+ [[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != *'/'* ]] || fail "invalid manifest entry"
188
152
  [[ -f "$root/$relative" && ! -L "$root/$relative" ]] || fail "manifest path is missing: $relative"
189
- actual="$(checksum_value "$root/$relative")"; [[ "$actual" == "$checksum" ]] || fail "checksum mismatch: $relative"
153
+ actual="$(checksum_value "$root/$relative")"
154
+ [[ "$actual" == "$checksum" ]] || fail "checksum mismatch: $relative"
155
+ count=$((count + 1))
190
156
  done < "$root/manifest.sha256"
191
- local actual_paths declared_paths
192
- actual_paths="$(cd "$root"; find . -type f ! -name manifest.sha256 -print | sed 's|^./||' | LC_ALL=C sort)"
193
- declared_paths="$(sed -n 's/^[0-9a-f]\{64\} //p' "$root/manifest.sha256" | LC_ALL=C sort)"
194
- [[ "$actual_paths" == "$declared_paths" ]] || fail "manifest coverage is incomplete"
157
+ [[ "$count" == 5 ]] || fail "manifest must contain exactly five entries"
158
+ [[ "$(sed -n 's/^[0-9a-f]\{64\} //p' "$root/manifest.sha256" | LC_ALL=C sort | tr '\n' '|')" == '.env|backend-image.tar|deploy.sh|docker-compose.yml|frontend-image.tar|' ]] || fail "manifest coverage is incomplete"
195
159
  }
196
160
 
197
- reject_version_secrets() {
198
- local root="$1" file base
199
- ! find "$root" -type l -print -quit | grep -q . || fail "symbolic links must not be packaged"
200
- while IFS= read -r file; do base="${file##*/}"; case "$base" in .env|.env.*|*.pem|*.key|id_rsa|id_ed25519|credentials|credentials.*) fail "secret file must not enter version directory: ${file#"$root"/}" ;; esac; done < <(find "$root" -type f -print)
161
+ validate_package_layout() {
162
+ local root="$1" entry name
163
+ for name in .env backend-image.tar deploy.sh docker-compose.yml frontend-image.tar manifest.sha256; do
164
+ [[ -f "$root/$name" && ! -L "$root/$name" ]] || fail "required package file is missing or unsafe: $name"
165
+ done
166
+ while IFS= read -r entry; do
167
+ name="${entry##*/}"
168
+ case "$name" in
169
+ .env|backend-image.tar|deploy.sh|docker-compose.yml|frontend-image.tar|manifest.sha256) ;;
170
+ logs) [[ -d "$entry" && ! -L "$entry" ]] || fail "logs must be a regular directory" ;;
171
+ *) fail "unexpected package entry: $name" ;;
172
+ esac
173
+ done < <(find "$root" -mindepth 1 -maxdepth 1 -print | LC_ALL=C sort)
201
174
  }
202
175
 
203
- validate_dockerignore() {
204
- local file="$1" pattern line
205
- [[ -f "$file" ]] || fail "required Docker ignore file is missing: $file"
206
- for pattern in '.env' '.env.*' '*.pem' '*.key' id_rsa id_ed25519 credentials 'credentials.*' 'deploy/' 'logs/' '.git/' 'node_modules/' 'venv/' '.pnpm-store/' '.deploy-build.lock/' '.deploy-build.*'; do grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"; done
207
- while IFS= read -r line || [[ -n "$line" ]]; do [[ "$line" != !* || "$line" == '!.env.example' ]] || fail "Docker ignore file contains forbidden reverse include: $line"; done < "$file"
176
+ archive_has_tag() {
177
+ local archive="$1" tag="$2" manifest compact count
178
+ [[ -f "$archive" && ! -L "$archive" ]] || fail "image archive is missing: $archive"
179
+ manifest="$(tar -xOf "$archive" manifest.json 2>/dev/null)" || fail "image archive is missing manifest.json: $archive"
180
+ compact="$(tr -d '[:space:]' <<< "$manifest")"
181
+ count="$(grep -o '"RepoTags"' <<< "$compact" | wc -l | tr -d ' ')"
182
+ [[ "$count" == 1 && "$compact" == *"\"RepoTags\":[\"$tag\"]"* ]] || fail "image archive must contain exactly the expected tag: $tag"
208
183
  }
209
184
 
210
- validate_local_package() {
211
- local output_root="$1" version="$2" version_root="$1/$2" compose="$1/$2/docker/docker-compose.yml"
212
- [[ -x "$output_root/deploy.sh" ]] || fail "generated deploy.sh is missing or not executable"
213
- cmp -s "$SCRIPT_PATH" "$output_root/deploy.sh" || fail "generated deploy.sh differs from its source"
214
- cmp -s "$SCRIPT_DIR/.env" "$output_root/.env" || fail "generated root .env differs from project .env"
215
- [[ "$(cat "$output_root/release.env")" == "RELEASE_VERSION=$version" ]] || fail "invalid release.env"
216
- [[ -f "$version_root/frontend-image.tar" && -f "$version_root/backend-image.tar" && -f "$compose" ]] || fail "dual image package is incomplete"
217
- [[ "$(grep -Ec '^ (frontend|backend):$' "$compose")" == 2 ]] || fail "Compose must define frontend and backend only"
218
- [[ "$(grep -Fc '../../logs:/app/logs' "$compose")" == 2 ]] || fail "both services must mount root logs"
219
- ! grep -Eqi 'blue|green|nginx-upstream|PROJECT_HTTP_PORT' "$compose" || fail "blue-green or host Nginx configuration is forbidden"
220
- [[ ! -e "$version_root/database" ]] || fail "release package must not contain database migration files"
221
- reject_version_secrets "$version_root"; verify_manifest "$version_root"
185
+ acquire_build_lock() {
186
+ BUILD_LOCK="$SCRIPT_DIR/.deploy-build.lock"
187
+ mkdir "$BUILD_LOCK" 2>/dev/null || fail "another build is active or a stale lock exists: $BUILD_LOCK"
222
188
  }
223
189
 
224
- acquire_build_lock() { BUILD_LOCK="$SCRIPT_DIR/.deploy-build.lock"; mkdir "$BUILD_LOCK" 2>/dev/null || fail "another build is active or stale lock exists: $BUILD_LOCK"; }
225
- release_build_lock() { [[ -n "${BUILD_LOCK:-}" && -d "$BUILD_LOCK" ]] && rmdir "$BUILD_LOCK"; }
190
+ cleanup_build() {
191
+ local status=$?
192
+ if [[ -n "${STAGING_ROOT:-}" && -d "$STAGING_ROOT" ]]; then rm -rf -- "$STAGING_ROOT"; fi
193
+ if [[ -n "${BUILD_LOCK:-}" && -d "$BUILD_LOCK" ]]; then rmdir "$BUILD_LOCK" 2>/dev/null || true; fi
194
+ return "$status"
195
+ }
196
+
197
+ ensure_deploy_output_scope() {
198
+ local deploy_dir="$1" archive_name="$2" entry
199
+ mkdir -p "$deploy_dir"
200
+ [[ -d "$deploy_dir" && ! -L "$deploy_dir" ]] || fail "deploy output must be a regular directory"
201
+ while IFS= read -r entry; do
202
+ [[ "${entry##*/}" == "$archive_name" && -f "$entry" && ! -L "$entry" ]] || fail "deploy directory contains unexpected entry: ${entry##*/}"
203
+ done < <(find "$deploy_dir" -mindepth 1 -maxdepth 1 -print)
204
+ }
226
205
 
227
206
  build_release() {
228
- local project_root="$SCRIPT_DIR" agents_file="$SCRIPT_DIR/AGENTS.md" today version raw_time build_time frontend_image backend_image
229
- local frontend_dockerfile backend_dockerfile dockerignore_file deploy_config_dir compose_source staging_parent output_root version_root agents_prepared old_deploy
230
- acquire_build_lock; staging_parent=; agents_prepared=; old_deploy=
231
- trap 'rm -rf -- "${staging_parent:-}" "${old_deploy:-}"; rm -f -- "${agents_prepared:-}"; release_build_lock' EXIT
232
- [[ -r "$agents_file" && -w "$agents_file" ]] || fail "project AGENTS.md must be readable and writable"
233
- load_dotenv "$project_root/.env"; validate_configuration
234
- frontend_dockerfile="$(resolve_project_path "$project_root" "${FRONTEND_DOCKERFILE:-src/frontend/Dockerfile}")"
235
- backend_dockerfile="$(resolve_project_path "$project_root" "${BACKEND_DOCKERFILE:-src/backend/Dockerfile}")"
236
- dockerignore_file="$(resolve_project_path "$project_root" "${DOCKERIGNORE_FILE:-.dockerignore}")"
237
- deploy_config_dir="$(resolve_project_path "$project_root" "${DEPLOY_CONFIG_DIR:-scripts/deployment}")"
238
- compose_source="$(resolve_project_path "$project_root" "${COMPOSE_SOURCE:-${deploy_config_dir#"$project_root"/}/docker-compose.yml}")"
239
- for command in node pnpm docker install find sort cmp awk sed grep; do require_command "$command"; done
207
+ local project_root="$SCRIPT_DIR" frontend_dockerfile backend_dockerfile compose_source dockerignore_file
208
+ 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
240
210
  select_checksum_tool
241
- [[ -f "$frontend_dockerfile" && -f "$backend_dockerfile" && -f "$compose_source" ]] || fail "Dockerfiles and deployment Compose are required"
211
+ load_dotenv "$project_root/.env"
212
+ validate_configuration
213
+ frontend_dockerfile="$project_root/src/frontend/Dockerfile"
214
+ backend_dockerfile="$project_root/src/backend/Dockerfile"
215
+ compose_source="$project_root/scripts/deployment/docker-compose.yml"
216
+ dockerignore_file="$project_root/.dockerignore"
217
+ for file in "$frontend_dockerfile" "$backend_dockerfile" "$compose_source"; do
218
+ [[ -f "$file" && ! -L "$file" ]] || fail "required generated deployment file is missing: ${file#"$project_root"/}"
219
+ done
242
220
  validate_dockerignore "$dockerignore_file"
243
- today="$(date +%Y%m%d)"; version="$(release_ledger next "$agents_file" "$today")"; assert_version "$version"
244
- frontend_image="${PROJECT_NAME}_frontend:$version"; backend_image="${PROJECT_NAME}_backend:$version"
245
- echo "release gate: pnpm test"; pnpm test
246
- echo "release gate: pnpm lint"; pnpm lint
247
- echo "release gate: pnpm typecheck"; pnpm typecheck
248
- echo "release gate: pnpm build"; RELEASE_VERSION="$version" pnpm build
249
- docker build --build-arg "FRONTEND_BASE_IMAGE=$FRONTEND_BASE_IMAGE" --build-arg "RELEASE_VERSION=$version" --tag "$frontend_image" --file "$frontend_dockerfile" "$project_root"
250
- docker build --build-arg "BACKEND_BASE_IMAGE=$BACKEND_BASE_IMAGE" --build-arg "RELEASE_VERSION=$version" --build-arg "BACKEND_PORT=$BACKEND_PORT" --tag "$backend_image" --file "$backend_dockerfile" "$project_root"
251
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$frontend_image")" == "$version" ]] || fail "frontend image version label mismatch"
252
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$backend_image")" == "$version" ]] || fail "backend image version label mismatch"
253
- staging_parent="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"; output_root="$staging_parent/deploy"; version_root="$output_root/$version"; agents_prepared="$project_root/.AGENTS.md.release.$$.tmp"
254
- mkdir -p "$version_root/docker"; install -m 0755 "$SCRIPT_PATH" "$output_root/deploy.sh"; install -m 0600 "$project_root/.env" "$output_root/.env"
255
- printf 'RELEASE_VERSION=%s\n' "$version" > "$output_root/release.env"
256
- docker save -o "$version_root/frontend-image.tar" "$frontend_image"; docker save -o "$version_root/backend-image.tar" "$backend_image"
257
- install -m 0644 "$compose_source" "$version_root/docker/docker-compose.yml"; cmp -s "$compose_source" "$version_root/docker/docker-compose.yml" || fail "generated Compose is not deterministic"
258
- create_manifest "$version_root"; validate_local_package "$output_root" "$version"
259
- raw_time="$(date '+%Y-%m-%d %H:%M:%S %z')"; build_time="${raw_time%?????}${raw_time: -5:3}:${raw_time: -2}"
260
- release_ledger prepare "$agents_file" "$version" "$build_time" "$agents_prepared"
261
- if [[ -e "$project_root/deploy" ]]; then old_deploy="$project_root/.deploy.previous.$$"; [[ ! -e "$old_deploy" ]] || fail "temporary deploy replacement path already exists"; mv "$project_root/deploy" "$old_deploy"; fi
262
- if ! mv "$output_root" "$project_root/deploy"; then [[ -n "$old_deploy" ]] && mv "$old_deploy" "$project_root/deploy"; fail "failed to publish deploy directory"; fi
263
- if ! mv "$agents_prepared" "$agents_file"; then rm -rf -- "$project_root/deploy"; [[ -n "$old_deploy" ]] && mv "$old_deploy" "$project_root/deploy"; fail "failed to write AGENTS.md release record"; fi
264
- [[ -n "$old_deploy" ]] && rm -rf -- "$old_deploy" && old_deploy=
265
- rm -rf -- "$staging_parent"; staging_parent=; release_build_lock; BUILD_LOCK=; trap - EXIT
266
- echo "release package created: deploy/$version"
221
+ docker image inspect "$FRONTEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local frontend base image is missing: $FRONTEND_BASE_IMAGE"
222
+ docker image inspect "$BACKEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local backend base image is missing: $BACKEND_BASE_IMAGE"
223
+ frontend_image="${PROJECT_NAME}_frontend:latest"
224
+ backend_image="${PROJECT_NAME}_backend:latest"
225
+ acquire_build_lock
226
+ 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"
229
+ docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "frontend image build did not create $frontend_image"
230
+ docker image inspect "$backend_image" >/dev/null 2>&1 || fail "backend image build did not create $backend_image"
231
+ STAGING_ROOT="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
232
+ package_root="$STAGING_ROOT/package"
233
+ mkdir -p "$package_root"
234
+ install -m 0600 "$project_root/.env" "$package_root/.env"
235
+ install -m 0755 "$SCRIPT_PATH" "$package_root/deploy.sh"
236
+ install -m 0644 "$compose_source" "$package_root/docker-compose.yml"
237
+ docker save -o "$package_root/frontend-image.tar" "$frontend_image"
238
+ docker save -o "$package_root/backend-image.tar" "$backend_image"
239
+ archive_has_tag "$package_root/frontend-image.tar" "$frontend_image"
240
+ archive_has_tag "$package_root/backend-image.tar" "$backend_image"
241
+ create_manifest "$package_root"
242
+ validate_package_layout "$package_root"
243
+ verify_manifest "$package_root"
244
+ deploy_dir="$project_root/deploy"
245
+ archive_name="${PROJECT_NAME}-docker.tar.gz"
246
+ ensure_deploy_output_scope "$deploy_dir" "$archive_name"
247
+ candidate="$STAGING_ROOT/$archive_name"
248
+ tar -czf "$candidate" -C "$package_root" .
249
+ tar -tzf "$candidate" >/dev/null || fail "generated delivery archive is invalid"
250
+ mv -f "$candidate" "$deploy_dir/$archive_name"
251
+ rm -rf -- "$STAGING_ROOT"
252
+ STAGING_ROOT=
253
+ rmdir "$BUILD_LOCK"
254
+ BUILD_LOCK=
255
+ trap - EXIT
256
+ echo "delivery archive created: deploy/$archive_name"
267
257
  }
268
258
 
269
- read_release_pointer() {
270
- local file="$DEPLOY_ROOT/release.env" line
271
- [[ -f "$file" && "$(wc -l < "$file" | tr -d ' ')" == 1 ]] || fail "release.env must contain exactly one line"
272
- line="$(cat "$file")"; [[ "$line" =~ ^RELEASE_VERSION=([0-9]{8}-[0-9]{3})$ ]] || fail "invalid release.env"
273
- RELEASE_VERSION="${BASH_REMATCH[1]}"; assert_version "$RELEASE_VERSION"
259
+ initialize_server_context() {
260
+ DEPLOY_ROOT="$SCRIPT_DIR"
261
+ for command in docker tar find sort grep sed awk; do require_command "$command"; done
262
+ select_checksum_tool
263
+ load_dotenv "$DEPLOY_ROOT/.env"
264
+ validate_configuration
265
+ [[ -f "$DEPLOY_ROOT/docker-compose.yml" && ! -L "$DEPLOY_ROOT/docker-compose.yml" ]] || fail "docker-compose.yml is missing"
266
+ mkdir -p "$DEPLOY_ROOT/logs"
274
267
  }
275
268
 
276
- initialize_server_context() {
277
- DEPLOY_ROOT="$SCRIPT_DIR"; load_dotenv "$DEPLOY_ROOT/.env"; validate_configuration; read_release_pointer
278
- for command in docker curl tar find sort awk sed grep; do require_command "$command"; done
279
- select_checksum_tool; mkdir -p "$DEPLOY_ROOT/logs"
269
+ compose() {
270
+ docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$DEPLOY_ROOT/docker-compose.yml" "$@"
280
271
  }
281
272
 
282
273
  record_operation() {
283
274
  local result="$1" timestamp
284
275
  [[ -n "$DEPLOY_ROOT" && -d "$DEPLOY_ROOT/logs" && -n "$OPERATION" ]] || return 0
285
276
  timestamp="$(date '+%Y-%m-%d %H:%M:%S %z')"
286
- printf '%s version=%s operation=%s result=%s\n' "$timestamp" "${RELEASE_VERSION:-unknown}" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
287
- }
288
-
289
- validate_uploaded_package() {
290
- local root="$DEPLOY_ROOT/$RELEASE_VERSION"
291
- [[ -f "$root/frontend-image.tar" && -f "$root/backend-image.tar" && -f "$root/docker/docker-compose.yml" ]] || fail "uploaded dual image package is incomplete"
292
- [[ ! -e "$root/database" ]] || fail "release package must not contain database migration files"
293
- reject_version_secrets "$root"; verify_manifest "$root"
294
- tar -tf "$root/frontend-image.tar" >/dev/null || fail "invalid frontend image archive"
295
- tar -tf "$root/backend-image.tar" >/dev/null || fail "invalid backend image archive"
296
- }
297
-
298
- archive_has_tag() {
299
- local archive="$1" tag="$2" manifest compact count
300
- manifest="$(tar -xOf "$archive" manifest.json 2>/dev/null)" || fail "image archive is missing manifest.json: $archive"
301
- compact="$(tr -d '[:space:]' <<< "$manifest")"; count="$(grep -o '"RepoTags"' <<< "$compact" | wc -l | tr -d ' ')"
302
- [[ "$count" == 1 && "$compact" == *"\"RepoTags\":[\"$tag\"]"* ]] || fail "image archive must contain exactly one expected tag: $tag"
303
- }
304
-
305
- image_names() { FRONTEND_IMAGE="${PROJECT_NAME}_frontend:$RELEASE_VERSION"; BACKEND_IMAGE="${PROJECT_NAME}_backend:$RELEASE_VERSION"; export FRONTEND_IMAGE BACKEND_IMAGE RELEASE_VERSION; }
306
-
307
- load_frontend_image() {
308
- local archive="$DEPLOY_ROOT/$RELEASE_VERSION/frontend-image.tar"
309
- archive_has_tag "$archive" "$FRONTEND_IMAGE"; docker load -i "$archive" >/dev/null
310
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$FRONTEND_IMAGE")" == "$RELEASE_VERSION" ]] || fail "frontend loaded image version mismatch"
311
- }
312
-
313
- load_backend_image() {
314
- local archive="$DEPLOY_ROOT/$RELEASE_VERSION/backend-image.tar"
315
- archive_has_tag "$archive" "$BACKEND_IMAGE"; docker load -i "$archive" >/dev/null
316
- [[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$BACKEND_IMAGE")" == "$RELEASE_VERSION" ]] || fail "backend loaded image version mismatch"
317
- }
318
-
319
- compose() {
320
- FRONTEND_IMAGE="$FRONTEND_IMAGE" BACKEND_IMAGE="$BACKEND_IMAGE" RELEASE_VERSION="$RELEASE_VERSION" docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$DEPLOY_ROOT/$RELEASE_VERSION/docker/docker-compose.yml" "$@"
277
+ printf '%s operation=%s result=%s\n' "$timestamp" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
278
+ }
279
+
280
+ deploy_stack() {
281
+ local frontend_image="${PROJECT_NAME}_frontend:latest" backend_image="${PROJECT_NAME}_backend:latest"
282
+ validate_package_layout "$DEPLOY_ROOT"
283
+ verify_manifest "$DEPLOY_ROOT"
284
+ archive_has_tag "$DEPLOY_ROOT/frontend-image.tar" "$frontend_image"
285
+ archive_has_tag "$DEPLOY_ROOT/backend-image.tar" "$backend_image"
286
+ docker load -i "$DEPLOY_ROOT/frontend-image.tar" >/dev/null
287
+ docker load -i "$DEPLOY_ROOT/backend-image.tar" >/dev/null
288
+ docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "loaded frontend image is missing: $frontend_image"
289
+ docker image inspect "$backend_image" >/dev/null 2>&1 || fail "loaded backend image is missing: $backend_image"
290
+ compose up -d --force-recreate --no-build --pull never --wait frontend backend
291
+ }
292
+
293
+ run_logged_operation() {
294
+ local operation="$1"
295
+ shift
296
+ OPERATION="$operation"
297
+ trap 'status=$?; if [[ $status -eq 0 ]]; then record_operation success; else record_operation failure; fi' EXIT
298
+ "$@"
299
+ trap - EXIT
300
+ record_operation success
321
301
  }
322
302
 
323
- verify_service() {
324
- local service="$1" port health version_url actual
325
- if [[ "$service" == frontend ]]; then port="$FRONTEND_PORT"; else port="$BACKEND_PORT"; fi
326
- health="$(endpoint_url "$DOCKER_BIND_IP" "$port" /health)"; version_url="$(endpoint_url "$DOCKER_BIND_IP" "$port" /version)"
327
- curl --fail --silent --show-error --retry 12 --retry-delay 2 --retry-connrefused "$health" >/dev/null
328
- actual="$(curl --fail --silent --show-error "$version_url")"
329
- [[ "$actual" == "$RELEASE_VERSION" ]] || fail "$service version mismatch: expected $RELEASE_VERSION, got $actual"
303
+ server_deploy() {
304
+ initialize_server_context
305
+ run_logged_operation deploy deploy_stack
330
306
  }
331
307
 
332
- run_or_replace() {
333
- local target="$1"
334
- validate_uploaded_package; image_names
335
- case "$target" in
336
- frontend) load_frontend_image; compose up -d --force-recreate --no-deps frontend; verify_service frontend ;;
337
- backend) load_backend_image; compose up -d --force-recreate --no-deps backend; verify_service backend ;;
338
- both) load_frontend_image; load_backend_image; compose up -d --force-recreate frontend backend; verify_service frontend; verify_service backend ;;
339
- *) fail "invalid deployment target: $target" ;;
340
- esac
308
+ server_stop() {
309
+ initialize_server_context
310
+ run_logged_operation stop compose stop frontend backend
341
311
  }
342
312
 
343
- stop_services() { image_names; compose stop frontend backend; }
344
-
345
- server_menu() {
346
- local choice status
313
+ server_status() {
347
314
  initialize_server_context
348
- printf '%s\n' '1. 运行或替换前端' '2. 运行或替换后端' '3. 运行或替换前后端' '4. 停止'
349
- IFS= read -r choice || fail "failed to read deployment menu selection"
350
- case "$choice" in
351
- 1) OPERATION=run-or-replace-frontend ;;
352
- 2) OPERATION=run-or-replace-backend ;;
353
- 3) OPERATION=run-or-replace-both ;;
354
- 4) OPERATION=stop-both ;;
355
- *) OPERATION=invalid-selection; record_operation failure; fail "invalid deployment menu selection: $choice" ;;
356
- esac
357
- trap 'status=$?; if [[ $status -eq 0 ]]; then record_operation success; else record_operation failure; fi' EXIT
358
- case "$choice" in
359
- 1) run_or_replace frontend ;;
360
- 2) run_or_replace backend ;;
361
- 3) run_or_replace both ;;
362
- 4) stop_services ;;
363
- esac
315
+ compose ps frontend backend
364
316
  }
365
317
 
366
- if [[ "$#" -eq 0 ]]; then server_menu
318
+ if [[ "$#" -eq 0 ]]; then server_deploy
367
319
  elif [[ "$#" -eq 1 && "$1" == build ]]; then build_release
320
+ elif [[ "$#" -eq 1 && "$1" == stop ]]; then server_stop
321
+ elif [[ "$#" -eq 1 && "$1" == status ]]; then server_status
368
322
  else usage
369
323
  fi