move_detect/server/deploy/build_cafe24_package.sh
유창욱 e00ad69aa3 feat(safety,ux): 통보·억제·증거 체계 신설 + UI/UX 감사 21건 반영
UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업.
두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다.

━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

동선 — 확인(ack) 경로가 위험했다
- 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를
  뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이
  밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서
  '확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리
  (전환해도 행끼리 상대 순서는 절대 안 바뀐다).
- 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라
  무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로.
- 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다.
- 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고,
  플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다.
- 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다.
- 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이
  통째로 꺼지는 경로다.

전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다
- KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다.
  KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의.
- 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장
  (그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다).
- 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리
  시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를
  넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다.
- 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로.

시각 — 화면마다 완성도가 달랐다
- 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고,
  납품 심사에서 "덜 만든 티"는 정확히 거기서 났다.
- 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다.
- 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선.
  캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다.

━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━

통보 — 이 제품의 정의에 해당하는 구멍
  지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에
  넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다.
- 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라
  어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가
  없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다.
- 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가
  아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다.
- 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등).
  '아무도 보지 않았다'는 사실 자체를 사건으로 만든다.
- 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간
  인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다.

조용한 실패 제거
- 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을
  전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속
  초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다.
- 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다.

끊겨 있던 배선 2건
- thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고
  /api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도
  장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py).
- 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수
  없었다. 지표는 있는데 그것을 켜는 사건이 없었다.

관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다)
- 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은
  엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다.
  목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다.
- 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고
  통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만
  의도적으로 withheld됐다'를 볼 수 있어야 한다.
- 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반).

증거·규정
- 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수
  없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시
  exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이
  매번 빨간불을 켜면 아무도 이 도구를 안 본다.
- 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 +
  조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다.
- 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만).

운영
- 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter),
  모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php),
  백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php).
- tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다.

미착수 4건 (모델·하드웨어가 선행 조건)
- 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다.
  감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다
  (edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록
  명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다.
- PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.

검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59).
Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→
행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개)
재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0,
상태 변경 핸들러 전부 CSRF+RBAC.
2026-08-18 07:51:23 +09:00

288 lines
16 KiB
Bash
Executable file

#!/usr/bin/env bash
# Build the Cafe24 upload package: a folder whose contents are dragged into the hosting
# account's FTP root as-is.
#
# THIS SCRIPT NEVER RUNS ON CAFE24. It runs here, on the build machine, and only produces a
# folder. The hosting account executes nothing — no shell, no composer, no installer: the
# operator uploads files over FTP and edits config.php. That is also why this lives under
# server/ and not deploy/ — deploy/ is the Raspberry Pi's, where scripts really are run as
# root on the target.
#
# Run on the BUILD machine (needs composer + php — dev-only tools, never on the host):
# server/deploy/build_cafe24_package.sh
#
# Output: dist/1_cafe24_upload/ and dist/move-detect-cafe24.zip
#
# dist/1_cafe24_upload/
# ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable.
# │ ├── config.php the ONLY file the operator edits
# │ ├── src/ vendor/ storage/snapshots/
# │ └── schema.sql import via phpMyAdmin, then DELETE from the host
# ├── www/
# │ └── move_detect/ -> UPLOAD into the existing www/. Reached at
# │ ├── index.php https://<domain>/move_detect/
# │ └── .htaccess _preflight.php assets/
# └── README-UPLOAD.md -> for the operator to read; not uploaded
#
# Two upload targets, and everything private is ONE folder. That matters on a shared account:
# this www/ already serves another site, so the FTP root may hold that site's files too —
# spraying src/, vendor/ and config.php across the root risks overwriting them (vendor/ and
# config.php are ordinary names in any PHP project).
#
# The app is mounted in a SUBDIRECTORY of www/ for the same reason: nothing secret may sit
# under a docroot the other site also serves. index.php finds move_detect_app/ by walking up
# rather than hardcoding '/..', and the URL prefix comes from SCRIPT_NAME, so this same
# package also works dropped straight into www/.
#
# APP_DIR= override the www/ subdirectory; empty mounts the app directly in www/.
# PRIVATE_DIR= override the private folder name (must match index.php's APP_PRIVATE_DIR).
set -euo pipefail
APP_DIR="${APP_DIR-move_detect}"
PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SERVER="$REPO_ROOT/server"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/dist}"
STAGE="$OUT_DIR/1_cafe24_upload"
ZIP="$OUT_DIR/move-detect-cafe24.zip"
command -v composer >/dev/null 2>&1 || { echo "ERROR: composer not found on the build machine." >&2; exit 1; }
command -v php >/dev/null 2>&1 || { echo "ERROR: php not found on the build machine." >&2; exit 1; }
echo ">> [1/6] clean stage: $STAGE"
rm -rf "$STAGE"
# WEB is where the app's own files land: www/move_detect by default, www/ when APP_DIR is
# empty. Everything else stays at the stage root = the FTP root = one level above www/.
WEB="$STAGE/www${APP_DIR:+/$APP_DIR}"
# PRIV holds everything that must never be web-reachable, in ONE folder so it cannot
# collide with the co-hosted site's files at the FTP root.
PRIV="$STAGE/$PRIVATE_DIR"
mkdir -p "$WEB" "$PRIV/src" "$PRIV/storage/snapshots"
echo ">> [2/6] generating the no-dev autoloader"
# public/index.php hard-requires ../vendor/autoload.php, so vendor/ MUST ship — the app has
# no runtime composer *packages* (composer.lock's "packages" is empty), but it does need
# composer's generated autoloader to map App\ -> src/.
#
# --no-dev is load-bearing, not tidiness: the working tree's vendor/ carries PHPUnit and a
# dev autoload_files.php that eagerly requires tests/Support/InMemoryRepositories.php on
# every request — a file this package deliberately excludes. Shipping it fatals every hit.
#
# --classmap-authoritative because src/Auth/Session.php declares three symbols
# (SessionStore, ArraySessionStore, PhpSessionStore) under one filename matching none of
# them; that is why composer.json carries an explicit classmap entry for it. Plain PSR-4
# would look for src/Auth/PhpSessionStore.php, miss, and fatal at index.php's
# `new PhpSessionStore()` — the 4th statement of every request.
#
# --no-interaction so a build box without a TTY cannot hang. Composer rewrites server/vendor/
# in place, so restore the dev autoloader on exit or local phpunit breaks.
restore_dev_autoloader() {
echo ">> restoring the dev autoloader in server/vendor (so local phpunit keeps working)"
( cd "$SERVER" && composer dump-autoload --no-interaction >/dev/null 2>&1 ) || true
}
trap restore_dev_autoloader EXIT
( cd "$SERVER" && composer dump-autoload --no-dev --classmap-authoritative --no-interaction )
# Precondition for everything below: this package stages ONLY composer's generated
# autoloader, which is correct exactly while the app has no runtime packages. The day
# someone adds one to composer.json's `require`, that staging silently omits its code and
# the host fatals on a missing class. Fail here, on the commit that adds the dependency,
# rather than on the hosting account.
php -r 'exit(json_decode(file_get_contents($argv[1]), true)["packages"] === [] ? 0 : 1);' \
"$SERVER/composer.lock" || {
echo "ERROR: composer.lock now lists runtime packages. This script stages only the" >&2
echo " autoloader, so their code would be missing from the package. Teach" >&2
echo " step [3/6] to stage the real vendor tree before shipping again." >&2
exit 1
}
echo ">> [3/6] staging vendor/ (autoloader only — no phpunit, no dev deps)"
mkdir -p "$PRIV/vendor/composer"
cp "$SERVER/vendor/autoload.php" "$PRIV/vendor/autoload.php"
# Copy only composer's own generated files, by name. A glob over vendor/* would drag in
# phpunit/, sebastian/, myclabs/ and friends.
for f in ClassLoader.php InstalledVersions.php autoload_classmap.php autoload_namespaces.php \
autoload_psr4.php autoload_real.php autoload_static.php platform_check.php; do
cp "$SERVER/vendor/composer/$f" "$PRIV/vendor/composer/$f"
done
cp "$SERVER/vendor/composer/LICENSE" "$PRIV/vendor/composer/" 2>/dev/null || true
# installed.php / installed.json are deliberately NOT copied. `dump-autoload` does not
# regenerate them — they are the package inventory written by the last `composer install`,
# so they still list every dev package (and its exact version, which is a free CVE shopping
# list for anyone who can read them). Nothing here needs them: autoload_real.php eagerly
# requires only platform_check.php, and Composer\InstalledVersions reads installed.php
# lazily, on a call this app never makes.
# Guard: the dev autoload_files.php requires tests/Support/InMemoryRepositories.php, which
# is not in this package. If it slipped through, every request 500s — fail the build loudly
# rather than ship it.
if [ -f "$PRIV/vendor/composer/autoload_files.php" ]; then
echo "ERROR: autoload_files.php present — that is the DEV autoloader (it requires tests/)." >&2
echo " composer dump-autoload --no-dev did not take effect." >&2
exit 1
fi
# Guard: no autoload map may point outside src/. Grep the maps that actually drive loading
# rather than the whole tree — InstalledVersions.php names PHPUnit in a docblock, which is
# harmless prose, and matching it would make this check cry wolf.
for map in autoload_classmap.php autoload_psr4.php autoload_static.php autoload_namespaces.php; do
if grep -qiE "phpunit|sebastian|myclabs|/tests/" "$PRIV/vendor/composer/$map"; then
echo "ERROR: $map references dev deps or tests/ — --no-dev did not take effect." >&2
exit 1
fi
done
# Guard: no dev package directory may exist in the staged vendor/.
if find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer | grep -q .; then
echo "ERROR: unexpected package directories under the staged vendor/:" >&2
find "$PRIV/vendor" -mindepth 1 -maxdepth 1 -type d ! -name composer >&2
exit 1
fi
echo ">> [4/6] staging app source"
cp -r "$SERVER/src/." "$PRIV/src/"
cp -r "$SERVER/public/." "$WEB/"
cp "$SERVER/schema.sql" "$PRIV/schema.sql"
# migrations/ ships too: a host that already imported an older schema.sql upgrades by
# running these (phpMyAdmin, date order) instead of re-importing — schema.sql is for a
# FRESH database only. Like schema.sql, delete from the host after applying.
cp -r "$SERVER/migrations" "$PRIV/migrations"
# tools/ ships INSIDE the private folder, never under www/. These are CLI entry points the host
# runs from cron (notification dispatch, retention) or by hand (backup, audit verification), and
# bootstrap.php refuses to run under any SAPI but CLI — but the real guarantee is that they are
# not web-reachable at all, because dispatch_alerts.php sends mail and backup.php reads every
# incident record in the database.
cp -r "$SERVER/tools" "$PRIV/tools"
# The hardened DocumentRoot .htaccess replaces server/public/'s dev-local rewrite-only one.
cp "$SERVER/deploy/htaccess.www" "$WEB/.htaccess"
cp "$SERVER/deploy/config.template.php" "$PRIV/config.php"
# Provenance stamp. A package's own contents are always self-consistent, so nothing inside it
# reveals that it was built before the last four feature commits — which is exactly how a stale
# package once shipped. Keep it OUT of the web root: it names the build, not a public asset.
{
echo "package: move-detect-cafe24"
echo "version: $(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)"
echo "git_commit: $(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "built_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$PRIV/BUILD-INFO.txt"
cp "$SERVER/deploy/preflight.php" "$WEB/_preflight.php"
# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On).
cp "$SERVER/deploy/user.ini" "$WEB/.user.ini"
cp "$SERVER/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md"
# `cp -r` above copies whatever is in server/src|public — defensively strip dev/tooling caches
# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served
# package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed.
find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \
-o -name '.pytest_cache' -o -name '.mypy_cache' -o -name '.DS_Store' \
-o -name '.phpunit.result.cache' -o -name 'Thumbs.db' \) -exec rm -rf {} + 2>/dev/null || true
# storage/snapshots must exist and be writable. Nothing in src/ ever creates it, and the
# failure is silent rather than loud: IngestController does
# `if (file_put_contents(...) === false) continue;`, so a missing dir means the snapshot is
# dropped while the API still answers 201 to the edge device. The event row lands in MySQL
# with no image, the dashboard shows an event with no photo, and with display_errors=Off
# (correct for production) not even a warning survives. That is permanent evidence loss.
touch "$PRIV/storage/snapshots/.gitkeep"
# Build-local only. Neither zip nor FTP carries unix modes, so these do NOT reach the host —
# they are correct for someone who rsyncs the stage directly, and a no-op otherwise. The
# host-side modes are the operator's job (README-UPLOAD.md) and _preflight.php verifies the
# live value rather than trusting these.
chmod 750 "$PRIV/storage/snapshots"
chmod 600 "$PRIV/config.php"
echo ">> [5/6] verifying the package resolves every class index.php touches"
# Prove the autoloader works against the STAGED layout, not the repo's.
php -r '
$stage = $argv[1];
require $stage . "/vendor/autoload.php";
$classes = ["App\\Config", "App\\Http\\Router", "App\\Http\\Request", "App\\Http\\Response",
"App\\Db\\Database", "App\\Auth\\PhpSessionStore", "App\\Auth\\AuthController",
"App\\Auth\\CsrfController", "App\\Auth\\Rbac", "App\\Ingest\\IngestController",
"App\\Ingest\\EventValidator", "App\\Ingest\\ImageProcessor", "App\\Api\\ConfigController",
"App\\Api\\HeartbeatController", "App\\Dashboard\\EventListController",
"App\\Dashboard\\EventDetailController", "App\\Dashboard\\SnapshotProxy",
"App\\Dashboard\\ZoneConfigController", "App\\Geometry\\Polygon",
"App\\Repository\\Pdo\\PdoDeviceRepository", "App\\Repository\\Pdo\\PdoNonceRepository",
"App\\Repository\\Pdo\\PdoRateLimiterRepository", "App\\Repository\\Pdo\\PdoEventRepository",
"App\\Repository\\Pdo\\PdoSnapshotRepository", "App\\Repository\\Pdo\\PdoUserRepository",
"App\\Repository\\Pdo\\PdoZoneRepository", "App\\Repository\\Pdo\\PdoThresholdRepository",
"App\\Repository\\Pdo\\PdoAuditRepository"];
$missing = array_values(array_filter($classes, fn($c) => !class_exists($c) && !interface_exists($c)));
if ($missing) { fwrite(STDERR, "ERROR: unresolvable in the package: " . implode(", ", $missing) . "\n"); exit(1); }
echo " -> " . count($classes) . " classes resolve through the packaged autoloader\n";
' "$PRIV"
# Prove index.php ACTUALLY finds $PRIV from where it is mounted, at the real depth, rather
# than trusting that the tree looks right. This is the check that would have caught a
# hardcoded '/..' — the walk-up must cross www/ and land on the private folder.
php -r '
[$_, $web, $priv] = $argv;
$found = null;
$isAppRoot = fn(string $d) => is_file("$d/config.php") && is_file("$d/vendor/autoload.php")
&& is_file("$d/src/Config.php");
for ($dir = dirname($web), $i = 0; $i < 4; $i++) {
foreach (["$dir/move_detect_app", $dir] as $c) { if ($isAppRoot($c)) { $found = $c; break 2; } }
if (dirname($dir) === $dir) break;
$dir = dirname($dir);
}
if ($found === null) { fwrite(STDERR, "ERROR: index.php could not resolve the app root from $web\n"); exit(1); }
if (realpath($found) !== realpath($priv)) {
fwrite(STDERR, "ERROR: app root resolved to $found, expected $priv\n"); exit(1);
}
echo " -> index.php resolves its app root across www/ to " . basename($found) . "/\n";
' "$WEB" "$PRIV"
# Every staged .php must parse under the build php. Catches a truncated copy.
while IFS= read -r -d '' f; do
php -l "$f" >/dev/null || { echo "ERROR: php -l failed: $f" >&2; exit 1; }
done < <(find "$STAGE" -name '*.php' -print0)
echo " -> every staged .php parses"
echo ">> [6/6] zipping"
rm -f "$ZIP"
if [ "${MAKE_ZIP:-0}" != "1" ]; then
# Default: leave the folder uncompressed. The operator uploads $STAGE/ over FTP as-is,
# which is also the only path that cannot mangle directory separators (see below).
echo " -> skipped (MAKE_ZIP=1 to also produce $ZIP)"
elif command -v zip >/dev/null 2>&1; then
( cd "$STAGE" && zip -rq "$ZIP" . )
elif command -v python3 >/dev/null 2>&1; then
# No `zip` (common on Windows/Git Bash). Use Python's zipfile with POSIX (forward-slash)
# arcnames. PowerShell's Compress-Archive writes BACKSLASH path separators, which Cafe24's
# extractor and some unzip tools do NOT normalise — the app tree then unpacks as flat files
# (move_detect_app\src\... as one filename) and the deploy silently breaks. Prefer python.
( cd "$STAGE" && python3 - "$ZIP" <<'PY'
import os, sys, zipfile
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as z:
for root, _dirs, files in os.walk("."):
for name in files:
full = os.path.join(root, name)
arc = os.path.relpath(full, ".").replace(os.sep, "/") # force forward slashes
z.write(full, arc)
PY
)
elif command -v powershell >/dev/null 2>&1; then
# Last resort. Compress-Archive uses backslash separators (see above) — the .htaccess/README
# note the operator to extract with a slash-aware tool, or prefer the python path.
win_stage="$(cygpath -w "$STAGE" 2>/dev/null || echo "$STAGE")"
win_zip="$(cygpath -w "$ZIP" 2>/dev/null || echo "$ZIP")"
powershell -NoProfile -Command "Compress-Archive -Path '${win_stage}\\*' -DestinationPath '${win_zip}' -Force"
echo " WARN: archived via Compress-Archive (backslash paths). Verify the host unpacks the tree correctly."
else
echo " WARN: no zip/python3/powershell — skipping the archive; upload $STAGE/ directly."
fi
echo ""
echo "DONE."
echo " package : $STAGE"
[ -f "$ZIP" ] && echo " zip : $ZIP"
echo ""
echo "Upload: put the CONTENTS of $STAGE into the Cafe24 FTP root."
echo " www/* merges into the existing www/ (DocumentRoot)"
echo " src/ vendor/ storage/ config.php sit BESIDE www/, never inside it"
echo ""
echo "Then, on the host: edit config.php (DB name/user/password), import schema.sql via"
echo "phpMyAdmin and delete it, and open https://<domain>/_preflight.php to confirm the PHP"
echo "build is usable — then DELETE _preflight.php. See README-UPLOAD.md."