@wdyy/skills 0.1.12 → 0.1.14

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 (20) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +37 -47
  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 +110 -0
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +161 -155
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +133 -95
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +109 -116
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +309 -298
  9. package/.well-known/skills/wdyy-deployment-standard/templates/backend.Dockerfile.template +17 -0
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +231 -252
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +18 -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 +3 -19
  14. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +7 -4
  15. package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +12 -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
  20. package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +0 -22
@@ -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,304 @@ 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 API_PREFIX VITE_API_BASE_URL
59
+ unset DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA
60
+ unset PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
43
61
  }
44
62
 
45
- is_deployment_key() {
63
+ is_configuration_key() {
46
64
  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)
65
+ 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)
50
66
  return 0 ;;
51
67
  *) return 1 ;;
52
68
  esac
53
69
  }
54
70
 
55
- is_removed_configuration_key() {
71
+ is_removed_key() {
56
72
  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)
73
+ 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
74
  return 0 ;;
62
75
  *) return 1 ;;
63
76
  esac
64
77
  }
65
78
 
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
79
  load_dotenv() {
73
- local file="$1" raw line key value first last loaded_key
80
+ local file="$1" raw line key value first last loaded
74
81
  local -a loaded_keys=()
75
- [[ -r "$file" ]] || fail "required root environment file is missing: $file"
82
+ [[ -r "$file" && -f "$file" && ! -L "$file" ]] || fail "required root environment file is missing or unsafe: $file"
76
83
  validate_env_permissions "$file"
77
84
  reset_configuration
78
85
  while IFS= read -r raw || [[ -n "$raw" ]]; do
79
86
  line="${raw%$'\r'}"
80
87
  [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
81
88
  [[ "$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
89
+ key="${BASH_REMATCH[1]}"
90
+ value="${BASH_REMATCH[2]}"
91
+ ! is_removed_key "$key" || fail "removed deployment configuration is forbidden: $key"
92
+ [[ "$value" != *'$('* && "$value" != *'`'* ]] || fail "executable syntax is forbidden in .env: $key"
93
+ for loaded in "${loaded_keys[@]:-}"; do [[ "$loaded" != "$key" ]] || fail "duplicate .env key: $key"; done
85
94
  loaded_keys+=("$key")
86
95
  if [[ ${#value} -ge 2 ]]; then
87
- first="${value:0:1}"; last="${value: -1}"
96
+ first="${value:0:1}"
97
+ last="${value: -1}"
88
98
  if [[ "$first" == "'" || "$first" == '"' ]]; then
89
99
  [[ "$last" == "$first" ]] || fail "unclosed quoted .env value: $key"
90
100
  value="${value:1:${#value}-2}"
91
101
  fi
92
102
  fi
93
- if is_deployment_key "$key"; then printf -v "$key" '%s' "$value"; export "$key"; fi
103
+ if is_configuration_key "$key"; then printf -v "$key" '%s' "$value"; fi
94
104
  done < "$file"
95
105
  }
96
106
 
97
107
  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
108
+ local key value
109
+ 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; do
110
+ value="${!key:-}"
111
+ [[ -n "$value" ]] || fail "required configuration is missing: $key"
112
+ done
101
113
  [[ "$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
114
+ [[ "$FRONTEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "FRONTEND_URL contains unsafe characters"
115
+ [[ "$BACKEND_URL" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "BACKEND_URL contains unsafe characters"
116
+ [[ "$API_PREFIX" =~ ^[A-Za-z][A-Za-z0-9_-]*$ ]] || fail "API_PREFIX must be one safe path segment"
117
+ [[ "$VITE_API_BASE_URL" == "/$API_PREFIX" ]] || fail "VITE_API_BASE_URL must equal /$API_PREFIX"
118
+ [[ "$DOCKER_BIND_IP" =~ ^[A-Za-z0-9:.%-]+$ ]] || fail "DOCKER_BIND_IP contains unsafe characters"
119
+ [[ "$DOCKER_PLATFORM" =~ ^linux/(amd64|arm64)$ ]] || fail "DOCKER_PLATFORM must be linux/amd64 or linux/arm64"
120
+ [[ "$DB_URL" =~ ^[A-Za-z0-9._:/?@\&=%-]+$ ]] || fail "DB_URL contains unsafe characters"
121
+ [[ "$FRONTEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "FRONTEND_BASE_IMAGE is not a valid image reference"
122
+ [[ "$BACKEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "BACKEND_BASE_IMAGE is not a valid image reference"
103
123
  assert_port FRONTEND_PORT "$FRONTEND_PORT"
104
124
  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
115
- }
116
-
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
125
+ assert_port DB_PORT "$DB_PORT"
126
+ [[ "$FRONTEND_PORT" != "$BACKEND_PORT" ]] || fail "FRONTEND_PORT and BACKEND_PORT must differ"
126
127
  }
127
128
 
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
129
+ assert_image_platform() {
130
+ local image="$1" actual
131
+ actual="$(docker image inspect --platform "$DOCKER_PLATFORM" --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null)" || fail "image platform is unavailable for $image: $DOCKER_PLATFORM"
132
+ [[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "image platform mismatch for $image: expected $DOCKER_PLATFORM, got $actual"
133
133
  }
134
134
 
135
- checksum_value() {
136
- if [[ "$CHECKSUM_TOOL" == sha256sum ]]; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi
135
+ assert_server_platform() {
136
+ local actual
137
+ actual="$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}' 2>/dev/null)" || fail "Docker Server platform is unavailable"
138
+ [[ -n "$actual" ]] || fail "Docker Server platform is empty"
139
+ [[ "$actual" =~ ^linux/(amd64|arm64)$ ]] || fail "unsupported Docker Server platform: $actual"
140
+ [[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "Docker Server platform mismatch: expected $DOCKER_PLATFORM, got $actual"
137
141
  }
138
142
 
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
143
+ validate_dockerignore() {
144
+ local file="$1" pattern line
145
+ [[ -f "$file" && ! -L "$file" ]] || fail "required Docker ignore file is missing: $file"
146
+ 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
147
+ grep -Fxq "$pattern" "$file" || fail "Docker ignore file is missing required pattern: $pattern"
148
+ done
149
+ while IFS= read -r line || [[ -n "$line" ]]; do
150
+ [[ "$line" != !* || "$line" == '!.env.example' ]] || fail "Docker ignore file contains forbidden reverse include: $line"
151
+ done < "$file"
175
152
  }
176
153
 
177
154
  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)
155
+ local root="$1" file checksum
156
+ : > "$root/manifest.sha256"
157
+ for file in .env backend-image.tar deploy.sh docker-compose.yml frontend-image.tar; do
158
+ checksum="$(checksum_value "$root/$file")"
159
+ printf '%s %s\n' "$checksum" "$file" >> "$root/manifest.sha256"
160
+ done
180
161
  }
181
162
 
182
163
  verify_manifest() {
183
- local root="$1" line checksum relative actual
184
- [[ -s "$root/manifest.sha256" ]] || fail "missing or empty manifest.sha256"
164
+ local root="$1" line checksum relative actual count=0
165
+ [[ -s "$root/manifest.sha256" && ! -L "$root/manifest.sha256" ]] || fail "missing or unsafe manifest.sha256"
185
166
  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"
167
+ checksum="${line%% *}"
168
+ relative="${line#* }"
169
+ [[ "$checksum" =~ ^[0-9a-f]{64}$ && "$relative" != "$line" && "$relative" != /* && "$relative" != *'/'* ]] || fail "invalid manifest entry"
188
170
  [[ -f "$root/$relative" && ! -L "$root/$relative" ]] || fail "manifest path is missing: $relative"
189
- actual="$(checksum_value "$root/$relative")"; [[ "$actual" == "$checksum" ]] || fail "checksum mismatch: $relative"
171
+ actual="$(checksum_value "$root/$relative")"
172
+ [[ "$actual" == "$checksum" ]] || fail "checksum mismatch: $relative"
173
+ count=$((count + 1))
190
174
  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"
175
+ [[ "$count" == 5 ]] || fail "manifest must contain exactly five entries"
176
+ [[ "$(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
177
  }
196
178
 
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)
179
+ validate_package_layout() {
180
+ local root="$1" entry name
181
+ for name in .env backend-image.tar deploy.sh docker-compose.yml frontend-image.tar manifest.sha256; do
182
+ [[ -f "$root/$name" && ! -L "$root/$name" ]] || fail "required package file is missing or unsafe: $name"
183
+ done
184
+ while IFS= read -r entry; do
185
+ name="${entry##*/}"
186
+ case "$name" in
187
+ .env|backend-image.tar|deploy.sh|docker-compose.yml|frontend-image.tar|manifest.sha256) ;;
188
+ logs) [[ -d "$entry" && ! -L "$entry" ]] || fail "logs must be a regular directory" ;;
189
+ *) fail "unexpected package entry: $name" ;;
190
+ esac
191
+ done < <(find "$root" -mindepth 1 -maxdepth 1 -print | LC_ALL=C sort)
201
192
  }
202
193
 
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"
194
+ archive_has_tag() {
195
+ local archive="$1" tag="$2" manifest compact count
196
+ [[ -f "$archive" && ! -L "$archive" ]] || fail "image archive is missing: $archive"
197
+ manifest="$(tar -xOf "$archive" manifest.json 2>/dev/null)" || fail "image archive is missing manifest.json: $archive"
198
+ compact="$(tr -d '[:space:]' <<< "$manifest")"
199
+ count="$(grep -o '"RepoTags"' <<< "$compact" | wc -l | tr -d ' ')"
200
+ [[ "$count" == 1 && "$compact" == *"\"RepoTags\":[\"$tag\"]"* ]] || fail "image archive must contain exactly the expected tag: $tag"
201
+ }
202
+
203
+ acquire_build_lock() {
204
+ BUILD_LOCK="$SCRIPT_DIR/.deploy-build.lock"
205
+ mkdir "$BUILD_LOCK" 2>/dev/null || fail "another build is active or a stale lock exists: $BUILD_LOCK"
208
206
  }
209
207
 
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"
208
+ cleanup_build() {
209
+ local status=$?
210
+ if [[ -n "${STAGING_ROOT:-}" && -d "$STAGING_ROOT" ]]; then rm -rf -- "$STAGING_ROOT"; fi
211
+ if [[ -n "${BUILD_LOCK:-}" && -d "$BUILD_LOCK" ]]; then rmdir "$BUILD_LOCK" 2>/dev/null || true; fi
212
+ return "$status"
222
213
  }
223
214
 
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"; }
215
+ ensure_deploy_output_scope() {
216
+ local deploy_dir="$1" archive_name="$2" entry
217
+ mkdir -p "$deploy_dir"
218
+ [[ -d "$deploy_dir" && ! -L "$deploy_dir" ]] || fail "deploy output must be a regular directory"
219
+ while IFS= read -r entry; do
220
+ [[ "${entry##*/}" == "$archive_name" && -f "$entry" && ! -L "$entry" ]] || fail "deploy directory contains unexpected entry: ${entry##*/}"
221
+ done < <(find "$deploy_dir" -mindepth 1 -maxdepth 1 -print)
222
+ }
226
223
 
227
224
  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
225
+ local project_root="$SCRIPT_DIR" frontend_dockerfile backend_dockerfile compose_source dockerignore_file
226
+ local frontend_image backend_image package_root deploy_dir archive_name candidate
227
+ for command in docker tar install find sort grep sed awk mktemp; do require_command "$command"; done
240
228
  select_checksum_tool
241
- [[ -f "$frontend_dockerfile" && -f "$backend_dockerfile" && -f "$compose_source" ]] || fail "Dockerfiles and deployment Compose are required"
229
+ load_dotenv "$project_root/.env"
230
+ validate_configuration
231
+ frontend_dockerfile="$project_root/src/frontend/Dockerfile"
232
+ backend_dockerfile="$project_root/src/backend/Dockerfile"
233
+ compose_source="$project_root/scripts/deployment/docker-compose.yml"
234
+ dockerignore_file="$project_root/.dockerignore"
235
+ for file in "$frontend_dockerfile" "$backend_dockerfile" "$compose_source"; do
236
+ [[ -f "$file" && ! -L "$file" ]] || fail "required generated deployment file is missing: ${file#"$project_root"/}"
237
+ done
242
238
  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"
239
+ docker image inspect "$FRONTEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local frontend base image is missing: $FRONTEND_BASE_IMAGE"
240
+ docker image inspect "$BACKEND_BASE_IMAGE" >/dev/null 2>&1 || fail "required local backend base image is missing: $BACKEND_BASE_IMAGE"
241
+ assert_image_platform "$FRONTEND_BASE_IMAGE"
242
+ assert_image_platform "$BACKEND_BASE_IMAGE"
243
+ frontend_image="${PROJECT_NAME}_frontend:latest"
244
+ backend_image="${PROJECT_NAME}_backend:latest"
245
+ acquire_build_lock
246
+ trap cleanup_build EXIT
247
+ 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"
248
+ 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"
249
+ docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "frontend image build did not create $frontend_image"
250
+ docker image inspect "$backend_image" >/dev/null 2>&1 || fail "backend image build did not create $backend_image"
251
+ assert_image_platform "$frontend_image"
252
+ assert_image_platform "$backend_image"
253
+ STAGING_ROOT="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
254
+ package_root="$STAGING_ROOT/package"
255
+ mkdir -p "$package_root"
256
+ install -m 0600 "$project_root/.env" "$package_root/.env"
257
+ install -m 0755 "$SCRIPT_PATH" "$package_root/deploy.sh"
258
+ install -m 0644 "$compose_source" "$package_root/docker-compose.yml"
259
+ docker save -o "$package_root/frontend-image.tar" "$frontend_image"
260
+ docker save -o "$package_root/backend-image.tar" "$backend_image"
261
+ archive_has_tag "$package_root/frontend-image.tar" "$frontend_image"
262
+ archive_has_tag "$package_root/backend-image.tar" "$backend_image"
263
+ create_manifest "$package_root"
264
+ validate_package_layout "$package_root"
265
+ verify_manifest "$package_root"
266
+ deploy_dir="$project_root/deploy"
267
+ archive_name="${PROJECT_NAME}-docker.tar.gz"
268
+ ensure_deploy_output_scope "$deploy_dir" "$archive_name"
269
+ candidate="$STAGING_ROOT/$archive_name"
270
+ env COPYFILE_DISABLE=1 tar --no-xattrs -czf "$candidate" -C "$package_root" .
271
+ tar -tzf "$candidate" >/dev/null || fail "generated delivery archive is invalid"
272
+ mv -f "$candidate" "$deploy_dir/$archive_name"
273
+ rm -rf -- "$STAGING_ROOT"
274
+ STAGING_ROOT=
275
+ rmdir "$BUILD_LOCK"
276
+ BUILD_LOCK=
277
+ trap - EXIT
278
+ echo "delivery archive created: deploy/$archive_name"
267
279
  }
268
280
 
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"
281
+ initialize_server_context() {
282
+ DEPLOY_ROOT="$SCRIPT_DIR"
283
+ for command in docker tar find sort grep sed awk; do require_command "$command"; done
284
+ select_checksum_tool
285
+ load_dotenv "$DEPLOY_ROOT/.env"
286
+ validate_configuration
287
+ [[ -f "$DEPLOY_ROOT/docker-compose.yml" && ! -L "$DEPLOY_ROOT/docker-compose.yml" ]] || fail "docker-compose.yml is missing"
288
+ mkdir -p "$DEPLOY_ROOT/logs"
274
289
  }
275
290
 
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"
291
+ compose() {
292
+ docker compose --env-file "$DEPLOY_ROOT/.env" --project-name "$PROJECT_NAME" -f "$DEPLOY_ROOT/docker-compose.yml" "$@"
280
293
  }
281
294
 
282
295
  record_operation() {
283
296
  local result="$1" timestamp
284
297
  [[ -n "$DEPLOY_ROOT" && -d "$DEPLOY_ROOT/logs" && -n "$OPERATION" ]] || return 0
285
298
  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" "$@"
299
+ printf '%s operation=%s result=%s\n' "$timestamp" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
300
+ }
301
+
302
+ deploy_stack() {
303
+ local frontend_image="${PROJECT_NAME}_frontend:latest" backend_image="${PROJECT_NAME}_backend:latest"
304
+ validate_package_layout "$DEPLOY_ROOT"
305
+ verify_manifest "$DEPLOY_ROOT"
306
+ archive_has_tag "$DEPLOY_ROOT/frontend-image.tar" "$frontend_image"
307
+ archive_has_tag "$DEPLOY_ROOT/backend-image.tar" "$backend_image"
308
+ assert_server_platform
309
+ docker load -i "$DEPLOY_ROOT/frontend-image.tar" >/dev/null
310
+ docker load -i "$DEPLOY_ROOT/backend-image.tar" >/dev/null
311
+ assert_image_platform "${PROJECT_NAME}_frontend:latest"
312
+ assert_image_platform "${PROJECT_NAME}_backend:latest"
313
+ docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "loaded frontend image is missing: $frontend_image"
314
+ docker image inspect "$backend_image" >/dev/null 2>&1 || fail "loaded backend image is missing: $backend_image"
315
+ compose up -d --force-recreate --no-build --pull never --wait frontend backend
316
+ }
317
+
318
+ run_logged_operation() {
319
+ local operation="$1"
320
+ shift
321
+ OPERATION="$operation"
322
+ trap 'status=$?; if [[ $status -eq 0 ]]; then record_operation success; else record_operation failure; fi' EXIT
323
+ "$@"
324
+ trap - EXIT
325
+ record_operation success
321
326
  }
322
327
 
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"
328
+ server_deploy() {
329
+ initialize_server_context
330
+ run_logged_operation deploy deploy_stack
330
331
  }
331
332
 
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
333
+ server_stop() {
334
+ initialize_server_context
335
+ run_logged_operation stop compose stop frontend backend
341
336
  }
342
337
 
343
- stop_services() { image_names; compose stop frontend backend; }
344
-
345
- server_menu() {
346
- local choice status
338
+ server_status() {
347
339
  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
340
+ compose ps frontend backend
364
341
  }
365
342
 
366
- if [[ "$#" -eq 0 ]]; then server_menu
343
+ if [[ "$#" -eq 0 ]]; then server_deploy
367
344
  elif [[ "$#" -eq 1 && "$1" == build ]]; then build_release
345
+ elif [[ "$#" -eq 1 && "$1" == stop ]]; then server_stop
346
+ elif [[ "$#" -eq 1 && "$1" == status ]]; then server_status
368
347
  else usage
369
348
  fi