diff --git a/.gitignore b/.gitignore index b03c34e..7cba83c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,8 @@ Thumbs.db # Pi provisioning (real device secret) — never commit deploy/device.env + +# 생성되는 사용 설명서. 소스는 server/deploy/manual/{manual.json,shots/} 이고, +# 납품 패키저가 빌드할 때마다 다시 만든다. 커밋된 사본을 두면 화면이 바뀐 뒤에도 그것이 +# 최신처럼 보이는데, 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다. +server/deploy/manual/사용설명서.html diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6164d2e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,93 @@ +# CHANGELOG — move-detect (작업장 안전 AI CCTV) + +버전 표기는 저장소 루트 `VERSION` 파일이 단일 출처입니다. +납품 산출물 파일명에도 같은 버전이 들어갑니다: +`move-detect-cafe24--.zip` / `move-detect-pi---.tar.gz` + +--- + +## 1.0.0 — 2026-08-18 · 첫 정식 납품 버전 + +0.9.0 까지가 "감지하는 시스템"이었다면, 1.0.0 은 **"감지에 대응하는 시스템"** 입니다. +그 차이를 만드는 것이 아래 첫 항목입니다. + +### 추가 — 통보 (이 버전의 이유) + +이전까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐습니다. 새벽 3시에 +넘어진 작업자는 DB 에 행 하나를 남기고 아침을 맞았습니다. + +- **현장 경광등·부저** (엣지 GPIO). 서버도 인터넷도 거치지 않고 감지한 노드가 직접 울립니다. + 정전·회선 장애로 업링크가 끊긴 순간은 오히려 위험이 높아지는 때이므로, 어떤 현장이든 + 이것부터 설정합니다. +- **메일 · 웹훅** 채널과 1분 주기 디스패처. 알림에는 현장 이미지를 싣지 않고 링크만 보냅니다 + (스냅샷은 개인정보이고, 메일링 리스트로 나가는 순간 인증 프록시의 통제를 벗어납니다). +- **미확인 경보 에스컬레이션**. 위급/높음이 정해진 시간 동안 확인되지 않으면 그 사실 자체가 + 사건이 되어 상위 담당자에게 통보됩니다. "아무도 보지 않았다"가 처음으로 기록에 남습니다. + +### 추가 — 조용한 실패 드러내기 + +- **카메라 화각 변경 · 가림 · 조도 저하 감지.** 위험구역 판정은 카메라가 어제와 같은 곳을 + 본다는 전제 위에 서 있는데, 청소하다 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 + 시스템은 계속 초록불이었습니다. +- **저장 공간 부족 경고.** 디스크가 차면 이미지 저장만 조용히 실패하고 화면은 멀쩡해 보입니다. +- **설정 지연 표시가 실제로 동작**하게 됐습니다. 지표는 있었지만 그것을 켜는 사건이 없었습니다. + +### 추가 — 관리 화면 (그전에는 DB 직접 수정이 유일한 방법) + +- 감지 임계값 편집 (허용 범위·단위·설명을 화면이 강제) +- 통보 경로 설정 +- 정비 중 알람 억제 — 사유 필수, 최대 8시간, 자동 만료. + **감지와 기록은 그대로 계속되고 통보만 보류**되며, 보류했다는 사실도 기록에 남습니다. +- 위험성평가 연계 · 교대 인수인계 · 설비 성능 추이 · 설치 점검(`/setup`) + +### 추가 — 증거와 규정 + +- **감사로그 위·변조 탐지** (해시체인). 수정·삭제·우회삽입이 전부 드러납니다. +- **사고조사 패키지 1클릭** — 전후 타임라인, 통보 이력, 위험요인, 조치 이력, 서명란. + 인쇄해서 그대로 제출할 수 있습니다. +- 현장별 현황 (본사 계정) + +### 변경 — 화면 (UI/UX 점검 21건) + +- **감지 스트림이 다시 시간순이 됐습니다.** 확인 여부가 순서를 바꾸던 탓에 사고 재구성이 + 불가능했고, 확인하면 그 행이 사라지며 아래가 밀려 올라와 다른 이벤트를 잘못 확인할 수 + 있었습니다. 확인은 이제 제자리에서 처리됩니다. +- 이벤트 상세에서 바로 확인/오탐 판정 · 구역 설정에 카메라 선택기 · 날짜로 조회 · + 경보음 음소거(새 위급 오면 자동 해제) · 일괄 확인(위급 제외) · 키보드 조작 +- 표본이 부족한 수치는 비율 대신 원시 숫자만 보여줍니다. 6건→8건을 "▲33% 악화"로 칠하면 + 안전회의에서 반박당하고, 그 순간 그 화면 전체가 신뢰를 잃습니다. + +### 도구 (`move_detect_app/tools/`, 웹에서 접근 불가) + +| 도구 | 용도 | +|------|------| +| `dispatch_alerts.php` | 알림 발송 (크론 1분) · `--dry-run` 지원 | +| `maintain.php` | 보존기간 정리 (크론 일 1회) | +| `backup.php` | DB 덤프 + 사진 아카이브 + 복구 안내 | +| `verify_audit_chain.php` | 감사로그 무결성 검증 | +| `seed_demo.php` | 시연·시험용 데모 데이터 (운영 DB 실행 금지) | + +### 업그레이드 (0.9.x → 1.0.0) + +`migrations/2026-08-09-safety-platform.sql` 을 적용합니다. **파일 안의 9번 블록은 DB 종류에 +따라 문법이 다릅니다** — MySQL 8.0.16+ / MariaDB / 제약 없음 세 경우의 안내가 파일에 +적혀 있으니 `SELECT VERSION();` 으로 확인 후 맞는 것을 실행하세요. + +적용 후 `config.php` 에 `public_base_url`, `timezone`, `escalate_after_seconds` 를 추가하고 +(템플릿 참조), 자동실행(크론) 2개를 등록해야 알림이 동작합니다 — README-UPLOAD.md 8번. + +### 알려진 제약 + +- **보호구 미착용 · 중장비 근접 · 화재 감지**: 감지기 배선과 이벤트 계약은 준비되어 있으나 + **추론 모델이 확보되지 않아 기본 비활성**입니다. 라이선스 조건을 만족하는 가중치와 Hailo + 컴파일이 선행되어야 합니다. 켜져 있지 않다는 사실을 화면에서도 숨기지 않습니다. +- 실장비 검증 잔여 항목은 `docs/delivery-audit.md` 의 "OFF-HW 잔여" 절을 참조하세요. + +--- + +## 0.9.0 — 2026-08-08 + +납품 감사(43건) 대응. CSP nonce 도입, 보안 헤더 PHP 이관, /logout CSRF, 계정 잠금 응답에서 +사용자명 열거 제거. 엣지 큐 flush 상한, 타이머 env 전수 검증, 프레임 스킵 시 미블러 얼굴 +유출 차단, systemd 워치독. PDO 통합 테스트 복구, 라우트 테이블 대조, 산출물↔소스 패리티 +검사, CI 신설. 납품물에 version/git_commit/built_at 스탬프. diff --git a/VERSION b/VERSION index ac39a10..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +1.0.0 diff --git a/deploy/README.md b/deploy/README.md index 8ed22bf..4762f8f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -81,15 +81,17 @@ deploy/bundle/build_offline_bundle.sh --cross # target aarch64 / cp311 Output: the folder `dist/2_pi_install/` (+ `MANIFEST.txt` listing the bundled wheels). Copy the WHOLE FOLDER across the air gap as-is — no archive step. -Set `MAKE_TARBALL=1` if you would rather transfer one -`dist/2_pi_install-aarch64-cp311-hailo8.tar.gz` file. +Set `MAKE_TARBALL=1` if you would rather transfer one file. The tarball is named +`dist/move-detect-pi---aarch64-cp311-hailo8.tar.gz` — version and +build date so a site can say exactly what it is running, and the arch/ABI/Hailo triple +because a hailo8 and a hailo8l bundle are byte-different and crash-loop on the wrong board. ## 2. Transfer + install (on the air-gapped Pi) Copy `dist/2_pi_install/` across the gap (USB, one-way transfer, etc.), then: ```bash -# tarball variant only (MAKE_TARBALL=1): tar xzf 2_pi_install-aarch64-cp311-hailo8.tar.gz +# tarball variant only (MAKE_TARBALL=1): tar xzf move-detect-pi--<날짜>-aarch64-cp311-hailo8.tar.gz cd 2_pi_install sudo ./install_offline.sh ``` diff --git a/deploy/bundle/build_offline_bundle.sh b/deploy/bundle/build_offline_bundle.sh index 61ee22c..7979522 100755 --- a/deploy/bundle/build_offline_bundle.sh +++ b/deploy/bundle/build_offline_bundle.sh @@ -158,7 +158,14 @@ SIGNED=no; [ -n "${SIGN_KEY:-}" ] && SIGNED=yes # Provenance. Without this, a bundle is unidentifiable once it leaves the build machine: the # SHA256SUMS inside a STALE bundle is perfectly self-consistent, so integrity checks pass while # the contents are months behind the source. version+commit is how a site reports what it runs. -BUNDLE_VERSION="$(cat "$REPO_ROOT/VERSION" 2>/dev/null || echo unknown)" +# VERSION is mandatory (rules/release-versioning.md). An "unknown" bundle cannot be matched to +# a commit after it leaves the build machine, which is exactly when the question gets asked. +[ -f "$REPO_ROOT/VERSION" ] || { + echo "ERROR: $REPO_ROOT/VERSION is missing. Refusing to build an unversioned bundle." >&2 + exit 1 +} +BUNDLE_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION")" +[ -n "$BUNDLE_VERSION" ] || { echo "ERROR: VERSION is empty." >&2; exit 1; } GIT_REV="$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo unknown)" GIT_DIRTY="" (cd "$REPO_ROOT" && git diff --quiet HEAD -- edge deploy 2>/dev/null) || GIT_DIRTY=" (uncommitted changes)" @@ -208,7 +215,7 @@ if [ "${MAKE_TARBALL:-0}" != "1" ]; then fi # Embed the Hailo arch in the filename: hailo8 and hailo8l bundles are byte-different but were # otherwise identically named, so the wrong one could be shipped to a board and crash-loop. -TARBALL="$OUT_DIR/2_pi_install-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz" +TARBALL="$OUT_DIR/move-detect-pi-${BUNDLE_VERSION}-$(date -u +%Y%m%d)-${TARGET_ARCH}-${PYABI}-${HAILO_ARCH}.tar.gz" tar -C "$OUT_DIR" -czf "$TARBALL" "2_pi_install" echo ">> DONE: $TARBALL" echo ">> transfer this single file across the air gap, then run install_offline.sh on the Pi." diff --git a/server/deploy/README-UPLOAD.md b/server/deploy/README-UPLOAD.md index dd12163..1fa93fa 100644 --- a/server/deploy/README-UPLOAD.md +++ b/server/deploy/README-UPLOAD.md @@ -177,10 +177,61 @@ VALUES ('pi-01', '3라인 낙상 카메라', '여기에_비밀키_붙여넣기', --- -## 8. 마지막 확인 +## 8. 알림 켜기 (자동 실행 등록) — **이걸 안 하면 알림이 안 갑니다** + +여기까지만 하면 시스템은 **감지는 하지만 아무에게도 알리지 않습니다.** 화면을 보고 있는 +사람에게만 보입니다. 새벽에 넘어진 작업자를 아무도 모른 채 아침을 맞게 됩니다. + +카페24 관리자 페이지 → **자동실행관리(크론)** 에서 아래 두 개를 등록하세요. + +| 주기 | 실행할 명령 | 하는 일 | +|------|-------------|---------| +| 1분마다 | `/usr/bin/php /home/<계정>/move_detect_app/tools/dispatch_alerts.php` | 알림 발송 · 미확인 경보 상위 통보 · 실패 재시도 | +| 하루 1회 (새벽) | `/usr/bin/php /home/<계정>/move_detect_app/tools/maintain.php` | 사진 보존기간 정리 · 기록 용량 관리 | + +`<계정>` 자리에는 카페24 FTP 계정명을 넣습니다. 경로가 헷갈리면 FTP 로 접속했을 때 +`move_detect_app` 폴더가 보이는 그 위치입니다. + +### 처음 켤 때는 반드시 시험 발송부터 + +잘못된 수신자 목록으로 첫 발송을 하면 그 현장에서는 두 번 다시 이 기능을 켜지 않게 됩니다. +크론에 넣기 전에 아래를 한 번 실행해 **누구에게 무엇이 갈지만** 확인하세요 (아무것도 보내지 +않습니다). + +```bash +php /home/<계정>/move_detect_app/tools/dispatch_alerts.php --dry-run +``` + +### 알림 경로 설정 + +로그인 후 상단 **[시스템 설정]** → **통보 경로** 에서 등록합니다. + +- **현장 경광등**을 가장 먼저 설정하세요. 라즈베리파이가 직접 울리므로 **인터넷이 끊겨도 + 동작하는 유일한 경로**입니다. 정전·회선 장애 때 오히려 위험이 높아지는 시간대에 유일하게 + 살아 있는 알림입니다. +- 메일·웹훅은 그 위에 얹는 보조 수단이고, 위 크론이 돌아야 나갑니다. +- `config.php` 의 `public_base_url` 이 비어 있으면 디스패처는 실행을 거부합니다 — 열리지 + 않는 링크가 담긴 알림을 보내는 것보다 안 보내는 편이 낫기 때문입니다. + +--- + +## 9. 설치 점검 화면 + +로그인 후 **`https://내도메인/move_detect/setup`** 을 엽니다. + +각 단계는 실제 상태를 조회해서 표시하므로, 체크가 켜졌다면 그 단계는 **정말로 동작하고 +있는 것**입니다. 장치 등록 · 장치 통신 · 카메라 감지 · 위험구역 · 통보 경로 순서대로 +확인하세요. + +--- + +## 10. 마지막 확인 - [ ] `https://내도메인/move_detect/login` 이 열린다 - [ ] admin 으로 로그인되고 대시보드가 뜬다 - [ ] `schema.sql` 을 서버에서 지웠다 - [ ] `www/move_detect/_preflight.php` 를 서버에서 지웠다 - [ ] `https://내도메인/move_detect_app/config.php` 가 안 열린다 (열리면 위치가 잘못된 것) +- [ ] 자동실행(크론) 2개가 등록되어 있다 — **없으면 알림이 나가지 않습니다** (8번) +- [ ] `[시스템 설정] → 통보 경로` 에 최소 1개가 '사용' 상태다 (현장 경광등 우선) +- [ ] `/setup` 화면의 모든 단계에 체크가 켜져 있다 (9번) diff --git a/server/deploy/build_cafe24_package.sh b/server/deploy/build_cafe24_package.sh index 3949708..a055665 100755 --- a/server/deploy/build_cafe24_package.sh +++ b/server/deploy/build_cafe24_package.sh @@ -11,7 +11,8 @@ # 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 +# Output: dist/1_cafe24_upload/ and (MAKE_ZIP=1) +# dist/move-detect-cafe24--.zip # # dist/1_cafe24_upload/ # ├── move_detect_app/ -> UPLOAD to the FTP root. Not web-reachable. @@ -45,7 +46,21 @@ 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" + +# Release identity. VERSION is the single source (rules/release-versioning.md) and its ABSENCE +# is fatal, not a fallback: a package named "unknown" is one nobody can match to a commit, and +# the whole point of stamping provenance is that a stale bundle is self-consistent and therefore +# undetectable from the inside. +[ -f "$REPO_ROOT/VERSION" ] || { + echo "ERROR: $REPO_ROOT/VERSION is missing. Refusing to build an unversioned package." >&2 + exit 1 +} +RELEASE_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION")" +[ -n "$RELEASE_VERSION" ] || { echo "ERROR: VERSION is empty." >&2; exit 1; } +BUILD_DAY="$(date -u +%Y%m%d)" +# {제품}-{VERSION}-{YYYYMMDD}.zip — a date alone cannot separate two builds of the same day, +# and a version alone cannot say which day's rebuild a site is running. +ZIP="$OUT_DIR/move-detect-cafe24-${RELEASE_VERSION}-${BUILD_DAY}.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; } @@ -162,7 +177,7 @@ cp "$SERVER/deploy/config.template.php" "$PRIV/config.php" # 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 "version: $RELEASE_VERSION" 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" @@ -170,6 +185,21 @@ 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" +# 납품 동봉 문서. 스테이지 루트(= FTP 최상위)에 두지만 www/ 밖이라 웹으로는 열리지 않는다. +# 감사 리포트를 함께 싣는 이유: "무엇을 점검했고 무엇이 잔여인가"를 패키지 자체가 답하지 +# 못하면, 인수인계 몇 달 뒤에 그 답을 아는 사람이 아무도 남지 않는다. +cp "$REPO_ROOT/CHANGELOG.md" "$STAGE/CHANGELOG.md" +cp "$REPO_ROOT/docs/delivery-audit.md" "$STAGE/납품점검-리포트.md" +# 역할별 사용 설명서. 화면이 바뀌면 캡처와 함께 다시 생성해야 하므로, 여기서 빌드까지 돌려 +# 소스(manual.json + shots/)와 산출물이 어긋난 채 납품되는 경우를 없앤다. 구 UI 캡처가 남은 +# 매뉴얼은 납품물 자체의 결함이다. +if command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1; then + PY_BIN="$(command -v python3 || command -v python)" + "$PY_BIN" "$SERVER/deploy/manual/build_manual.py" --out "$STAGE/사용설명서.html" || { echo "ERROR: 사용설명서 생성 실패 — 캡처(shots/)가 빠졌는지 확인하세요." >&2; exit 1; } +else + echo "ERROR: python not found; 사용설명서를 생성할 수 없습니다." >&2 + exit 1 +fi # `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 diff --git a/server/deploy/manual/build_manual.py b/server/deploy/manual/build_manual.py new file mode 100644 index 0000000..8d00f9e --- /dev/null +++ b/server/deploy/manual/build_manual.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""역할별 사용자 설명서 생성기 — 캡처를 base64 로 박아 넣은 단일 HTML. + +WHY A SINGLE FILE + 납품처는 폐쇄망이고, 매뉴얼은 메일로 돌거나 USB 로 옮겨 다닌다. 이미지가 옆 폴더에 + 있으면 그 순간 도판 없는 문서가 된다. 전부 한 파일에 넣으면 어디로 옮겨도 온전하다. + +WHY REGENERATE INSTEAD OF EDITING HTML + 화면이 바뀌면 도판도 바뀌어야 한다. 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다 + (rules: delivery-audit '매뉴얼 캡처 실물 정합'). 내용은 manual.json, 도판은 shots/ 에 + 두고 이 스크립트로 다시 뽑는 구조라, 화면이 바뀌면 캡처만 갈아끼우고 재실행하면 된다. + + 사용법: + python server/deploy/manual/build_manual.py # -> 사용설명서.html + python server/deploy/manual/build_manual.py --out X # 출력 경로 지정 +""" +from __future__ import annotations + +import argparse +import base64 +import datetime +import html +import json +import pathlib +import sys + +HERE = pathlib.Path(__file__).resolve().parent +REPO = HERE.parents[2] + +CALLOUT_LABEL = {"info": "참고", "warn": "주의", "danger": "경고"} + + +def data_uri(path: pathlib.Path) -> str: + return "data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii") + + +CSS = """ +:root{ + --bg:#eef1f5; --card:#fff; --line:#dde3ea; --line-2:#eef2f6; + --ink:#131824; --ink-2:#464e5c; --ink-3:#646c7a; + --accent:#0b6b7a; --accent-wash:#e2f0f3; --accent-line:#b9d9df; + --warn-bg:#fbf6e6; --warn-line:#efe0b4; --warn-ink:#7a5606; + --crit-bg:#fdf2f2; --crit-line:#f2cccc; --crit-ink:#a51d1d; + --font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif; + --mono:ui-monospace,"Cascadia Mono",Consolas,"D2Coding",monospace; +} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font); + line-height:1.75;-webkit-font-smoothing:antialiased} +.wrap{max-width:880px;margin:0 auto;padding:0 1.2rem 5rem} + +/* 표지 */ +.cover{padding:3.5rem 0 2rem;border-bottom:3px solid var(--ink);margin-bottom:2rem} +.cover .kind{margin:0;font-size:.72rem;font-weight:700;letter-spacing:.14em; + text-transform:uppercase;color:var(--ink-3)} +.cover h1{margin:.5rem 0 .3rem;font-size:2.1rem;letter-spacing:-.02em} +.cover .sub{margin:0;color:var(--ink-2);font-size:1rem} +.cover .meta{margin:1.2rem 0 0;font-family:var(--mono);font-size:.76rem;color:var(--ink-3)} + +/* 목차 */ +.toc{background:var(--card);border:1px solid var(--line);border-radius:10px; + padding:1.1rem 1.3rem;margin-bottom:2.5rem} +.toc h2{margin:0 0 .6rem;font-size:.72rem;font-weight:700;letter-spacing:.08em; + text-transform:uppercase;color:var(--ink-3)} +.toc ul{margin:.2rem 0 0;padding-left:1.1rem;list-style:none} +.toc li{margin:.15rem 0} +.toc li::before{content:"·";color:var(--ink-3);margin-right:.5rem} +.toc a{color:var(--accent);text-decoration:none} +.toc a:hover{text-decoration:underline;text-underline-offset:3px} +.toc-role{margin:.9rem 0 0;font-weight:700;color:var(--ink);font-size:.9rem} +.toc-role:first-of-type{margin-top:0} + +/* 역할 */ +.role{margin:0 0 3rem} +.role-head{padding:1rem 1.2rem;background:var(--accent-wash); + border:1px solid var(--accent-line);border-radius:10px;margin-bottom:1.5rem} +.role-head h2{margin:0;font-size:1.25rem;color:var(--accent)} +.role-head p{margin:.35rem 0 0;font-size:.9rem;color:var(--ink-2)} + +section.step{background:var(--card);border:1px solid var(--line);border-radius:10px; + padding:1.3rem 1.4rem 1.5rem;margin-bottom:1rem} +section.step h3{margin:0 0 .8rem;font-size:1.05rem;letter-spacing:-.01em} +section.step p{margin:0 0 .7rem;font-size:.94rem;color:var(--ink-2)} +section.step p:last-child{margin-bottom:0} +section.step strong{color:var(--ink)} +figure{margin:0 0 1.1rem} +figure img{width:100%;height:auto;display:block;border:1px solid var(--line);border-radius:8px} +figcaption{margin-top:.35rem;font-size:.74rem;color:var(--ink-3)} + +.callout{margin:1rem 0 0;padding:.75rem .9rem;border-radius:8px;font-size:.9rem; + border:1px solid var(--line);background:var(--line-2);color:var(--ink-2)} +.callout .tag{display:inline-block;font-size:.68rem;font-weight:700;letter-spacing:.06em; + margin-right:.45rem;text-transform:uppercase} +.callout.warn{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn-ink)} +.callout.danger{background:var(--crit-bg);border-color:var(--crit-line);color:var(--crit-ink)} +.callout.info{background:var(--accent-wash);border-color:var(--accent-line);color:var(--accent)} + +code{font-family:var(--mono);font-size:.86em;background:var(--line-2); + padding:.1em .35em;border-radius:4px} +kbd{font-family:var(--mono);font-size:.8em;background:var(--card);border:1px solid var(--line); + border-bottom-width:2px;border-radius:4px;padding:.05em .4em} +.chip{display:inline-block;font-size:.72em;font-weight:700;color:#fff;background:var(--crit-ink); + padding:.1em .5em;border-radius:999px} + +/* FAQ */ +.faq{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:1.3rem 1.4rem} +.faq h2{margin:0 0 1rem;font-size:1.15rem} +.faq dt{font-weight:700;margin-top:1rem;color:var(--ink)} +.faq dt:first-of-type{margin-top:0} +.faq dd{margin:.3rem 0 0;font-size:.93rem;color:var(--ink-2)} + +footer.doc{margin-top:2.5rem;padding-top:1rem;border-top:1px solid var(--line); + font-size:.76rem;color:var(--ink-3);line-height:1.7} + +@media print{ + body{background:#fff} + .wrap{max-width:none;padding:0} + section.step,.toc,.faq,.role-head{break-inside:avoid;box-shadow:none} + .toc{display:none} +} +""" + + +def render(doc: dict, shots: pathlib.Path, version: str) -> str: + out: list[str] = [] + a = out.append + today = datetime.date.today().isoformat() + + a("") + a('') + a('') + a(f"{html.escape(doc['title'])}") + a(f"") + a("
") + + # 표지 + a('
') + a('

사용 설명서

') + a(f"

{html.escape(doc['title'])}

") + a(f"

{html.escape(doc['subtitle'])}

") + a(f'

버전 {html.escape(version)} · {today} 기준 화면

') + a("
") + + # 목차. 항목 번호는 제목이 이미 달고 있으므로(1., 2., …) 목록 자체는 번호를 매기지 + # 않는다. 역할 이름을
  • 로 끼워 넣고 list-style 로만 숨기면 그 줄이 카운터를 먹어 + # 본문 번호와 목차 번호가 어긋난다(7 다음이 9로 뛴다). + a('") + + # 본문 + for role in doc["roles"]: + a('
    ') + a('
    ') + a(f"

    {html.escape(role['name'])}

    ") + a(f"

    {html.escape(role['summary'])}

    ") + a("
    ") + for sec in role["sections"]: + a(f'
    ') + a(f"

    {html.escape(sec['heading'])}

    ") + shot = sec.get("shot") + if shot: + p = shots / shot + if not p.is_file(): + # 도판 누락을 조용히 넘기면 구멍 난 매뉴얼이 납품된다. + raise SystemExit(f"ERROR: 캡처가 없습니다: {p}") + a("
    ") + a(f'{html.escape(sec[') + a(f"
    {html.escape(sec['heading'])} — 실제 화면
    ") + a("
    ") + for para in sec["body"]: + a(f"

    {para}

    ") # 본문은 의도된 인라인 마크업을 허용한다 + co = sec.get("callout") + if co: + label = CALLOUT_LABEL.get(co["kind"], "참고") + a(f'
    ' + f'{label}{co["text"]}
    ') + a("
    ") + a("
    ") + + # FAQ + a('

    자주 묻는 질문

    ') + for item in doc["faq"]: + a(f"
    {html.escape(item['q'])}
    {item['a']}
    ") + a("
    ") + + a('
    ') + a(f"이 문서는 버전 {html.escape(version)} 화면을 기준으로 작성되었습니다. " + "화면이 바뀌면 캡처와 함께 다시 생성됩니다.
    " + "설치·업그레이드 절차는 README-UPLOAD.md, " + "변경 내역은 CHANGELOG.md, " + "점검 결과는 납품점검-리포트.md 를 참조하세요.") + a("
    ") + + a("
  • ") + return "\n".join(out) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=str(HERE / "사용설명서.html")) + ap.add_argument("--shots", default=str(HERE / "shots")) + args = ap.parse_args() + + version_file = REPO / "VERSION" + if not version_file.is_file(): + raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 매뉴얼은 만들지 않습니다.") + version = version_file.read_text(encoding="utf-8").strip() + + doc = json.loads((HERE / "manual.json").read_text(encoding="utf-8")) + out = pathlib.Path(args.out) + out.write_text(render(doc, pathlib.Path(args.shots), version), encoding="utf-8") + size_kb = out.stat().st_size / 1024 + print(f"{out} ({size_kb:.0f} KB, 단일 파일 · 이미지 포함)", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/server/deploy/manual/manual.json b/server/deploy/manual/manual.json new file mode 100644 index 0000000..61b3d0a --- /dev/null +++ b/server/deploy/manual/manual.json @@ -0,0 +1,202 @@ +{ + "title": "AI SAFETY CCTV 사용 설명서", + "subtitle": "작업장 안전 관제 · 역할별 안내", + "roles": [ + { + "id": "operator", + "name": "운영자 (관제 담당)", + "summary": "화면을 보며 감지된 사건을 확인하고 판정합니다. 하루 대부분의 시간을 [이벤트] 화면에서 보냅니다.", + "sections": [ + { + "heading": "1. 감지 스트림 읽는 법", + "shot": "events.jpg", + "body": [ + "목록은 항상 최근 순서입니다. 확인 여부와 무관하게 시간순으로만 정렬되므로, 사고가 났을 때 '그 시각 전후에 무슨 일이 있었나'를 그대로 따라 읽을 수 있습니다.", + "각 줄의 왼쪽은 증거 사진입니다. 사진을 눌러도, 사건 이름을 눌러도 상세 화면으로 갑니다.", + "확인 필요 표시가 붙은 줄이 아직 아무도 처리하지 않은 사건입니다. 심각도가 높을수록 표시가 진합니다.", + "왼쪽 장치·카메라 목록은 '지금 무엇이 보고 있는가'를 말합니다. 빨갛게 바뀌면 그 장치가 신호를 보내지 않는 것이고, 그 카메라는 감시 공백입니다." + ], + "callout": { + "kind": "warn", + "text": "장치가 오프라인이면 그 카메라는 아무것도 감지하지 못합니다. 화면이 조용한 것과 카메라가 죽은 것은 다릅니다 — 목록의 색을 먼저 보세요." + } + }, + { + "heading": "2. 확인 · 오탐 판정", + "body": [ + "확인은 '내가 봤고 처리했다'는 뜻입니다. 누르면 그 줄이 제자리에서 확인됨(이름)으로 바뀝니다. 목록에서 사라지지 않으므로, 방금 무엇을 처리했는지 눈으로 확인할 수 있습니다.", + "오탐은 '감지가 틀렸다'는 뜻입니다. 한 번 누르면 오탐 확정?으로 바뀌고, 4초 안에 한 번 더 눌러야 처리됩니다. 되돌릴 수 없고 카메라별 오탐율 통계에 반영되기 때문입니다.", + "확인과 오탐은 모두 기록에 남습니다(누가, 언제). 취소 기능은 일부러 넣지 않았습니다 — 안전 기록을 조용히 되돌릴 수 있으면 그 기록의 가치가 사라집니다." + ] + }, + { + "heading": "3. 밀린 사건 처리 · 키보드 조작", + "body": [ + "미확인만 탭으로 바꾸면 처리할 것만 남습니다. 순서는 그대로이므로 사건 사이의 관계가 흐트러지지 않습니다.", + "일괄 확인 버튼은 화면에 보이는 미확인 사건을 한 번에 처리합니다. 단 위급은 제외됩니다 — 넘어짐은 반드시 한 건씩 눈으로 봐야 하기 때문입니다.", + "키보드: J/K 위아래 이동 · A 확인 · F 오탐 · Enter 상세 열기." + ] + }, + { + "heading": "4. 사건 상세 — 무슨 일이 있었는지 보기", + "shot": "event-detail.jpg", + "body": [ + "증거 사진 3장이 이전 → 직전 → 발생 시점 순으로 놓입니다. 결과만이 아니라 사건이 어떻게 진행됐는지를 봅니다.", + "얼굴은 촬영 시점에 자동으로 흐리게 처리되어 저장됩니다. 원본 얼굴은 어디에도 남지 않습니다.", + "이 화면에서 바로 확인 처리·오탐을 누를 수 있습니다. 목록으로 돌아가 그 줄을 다시 찾을 필요가 없습니다.", + "오른쪽 개선 조치 기록에 조치를 남기면, 리포트가 조치 전후의 감지 밀도를 자동으로 비교해 줍니다." + ] + }, + { + "heading": "5. 특정 날짜 찾아보기", + "body": [ + "왼쪽 날짜 조회에 날짜를 넣고 [조회]를 누르면 그날 하루만 봅니다. 사고 조사에서 '지난주 화요일 오후'를 찾을 때 씁니다.", + "조회 중에도 실시간 감시는 계속 돌아갑니다. 화면 위 배너의 [실시간 스트림으로]를 누르면 돌아옵니다." + ] + }, + { + "heading": "6. 경보음", + "body": [ + "미확인 위급 사건이 있으면 경보음이 반복됩니다.", + "[경보음 끄기]로 잠시 끌 수 있지만, 새 위급 사건이 오면 자동으로 다시 켜집니다. 밀린 사건 때문에 끈 것이 다음 사고를 놓치는 이유가 되지 않게 하기 위해서입니다.", + "음소거는 이 화면에서만, 이번 접속에만 적용됩니다. 새로고침하면 다시 켜집니다." + ] + } + ] + }, + { + "id": "manager", + "name": "안전관리자", + "summary": "주간 안전회의 자료를 만들고, 사고가 나면 조사 자료를 제출하고, 위험구역과 위험성평가를 관리합니다.", + "sections": [ + { + "heading": "7. 안전 리포트 — 주간 회의 자료", + "shot": "report.jpg", + "body": [ + "상단 네 숫자가 그 기간의 요약입니다. 각 숫자 아래 이전 기간 대비 화살표가 붙어 추세를 함께 보여줍니다.", + "니어미스 : 사고 비율은 '사고 전 신호가 먼저 잡히고 있는가'를 봅니다. 니어미스가 많다는 것은 예방이 작동하고 있다는 뜻입니다.", + "[인쇄] 버튼을 누르면 화면 장식이 빠지고 A4 한 장으로 정리됩니다. 그대로 회의 자료로 씁니다.", + "표본이 적은 수치는 비율 대신 원시 숫자만 보여줍니다. 6건이 8건이 된 것을 '33% 악화'라고 적으면 회의에서 반박당하고, 그 순간 이 자료 전체가 신뢰를 잃기 때문입니다." + ] + }, + { + "heading": "8. 사고조사 패키지", + "shot": "package.jpg", + "body": [ + "사건 상세 화면의 [사고조사 패키지 →]를 누르면 만들어집니다.", + "한 장에 들어가는 것: 사건 개요와 대응 소요 시간, 증거 사진, 같은 카메라의 전후 30분 타임라인, 누구에게 언제 알림이 갔는지, 해당 위험요인과 통제 대책, 그 카메라의 개선 조치 이력, 그리고 서명란.", + "[인쇄 · PDF 저장]으로 그대로 제출할 수 있습니다.", + "사고 다음 날 이 자료를 손으로 모을 시간이 있는 사람은 없습니다. 그래서 한 번에 나오게 만들었습니다." + ] + }, + { + "heading": "9. 위험구역 그리기", + "shot": "zones.jpg", + "body": [ + "① 장치와 카메라를 고릅니다. 고르면 그 카메라의 최근 화면과 저장된 구역이 자동으로 뜹니다.", + "② 등급을 정하고 화면을 클릭해 점을 3개 이상 찍습니다. 경고는 진입 시 알림, 제한은 진입 자체가 위반, 협착은 끼임 위험 구역입니다.", + "③ 필요하면 점유 규칙을 넣습니다. 최소 인원(2인 1조 강제) · 최대 인원(정원 초과) · 체류 상한.", + "[이 구역 저장]을 누르면 다음 설정 동기화 때 장비로 내려갑니다." + ], + "callout": { + "kind": "warn", + "text": "카메라를 건드리면 구역을 다시 그려야 합니다. 각도가 바뀌면 구역이 엉뚱한 바닥을 가리키게 됩니다. 시스템이 화각 변경을 감지하면 카메라 화각 변경 사건으로 알려주니, 그 사건이 뜨면 반드시 이 화면에서 확인하세요." + } + }, + { + "heading": "10. 안전 관리 — 인수인계 · 정비 억제 · 위험성평가", + "shot": "compliance.jpg", + "body": [ + "교대 인수인계: 교대 시작 때 [인수 확인]을 누르면 '누가 어떤 미처리 건을 넘겨받았는지'가 기록됩니다. 숫자는 시스템이 직접 세므로 실제보다 적게 적힐 수 없습니다.", + "정비 중 알람 억제: 정비·점검처럼 계획된 작업 때 알림만 잠시 멈춥니다. 사유는 필수이고 최대 8시간이며 시간이 지나면 자동 해제됩니다.", + "위험성평가 연계: 각 감지 유형이 어떤 위험요인을 대신 보고 있는지 기록합니다. 사고조사 패키지에 이 내용이 함께 실립니다.", + "감시 설비 성능 추이: 장치가 '지금 살아 있는가'가 아니라 '점점 나빠지고 있는가'를 봅니다. 열화로 죽는 장비는 멈추기 며칠 전부터 프레임률이 흘러내립니다." + ], + "callout": { + "kind": "info", + "text": "억제 중에도 감지와 기록은 그대로 계속됩니다. 알림만 보류되고, 보류했다는 사실도 기록에 남습니다 — 나중에 그 시간대에 무슨 일이 있었는지 반드시 볼 수 있어야 하기 때문입니다." + } + } + ] + }, + { + "id": "admin", + "name": "시스템 관리자", + "summary": "설치·설정·알림 경로를 담당합니다. 평소에는 볼 일이 없고, 설치할 때와 무언가 바꿀 때만 봅니다.", + "sections": [ + { + "heading": "11. 설치 점검", + "shot": "setup.jpg", + "body": [ + "[시스템 설정] 화면 상단의 설치 점검 링크, 또는 주소창에 /setup.", + "각 단계는 실제 상태를 조회해서 표시됩니다. 체크가 켜졌다면 그 단계는 정말로 동작하고 있는 것입니다.", + "설치 직후 반드시 한 번 열어보세요. 화면이 멀쩡히 뜨는데 아무것도 감시하지 않는 상태가 이 제품에서 가장 위험한 구간입니다." + ], + "callout": { + "kind": "danger", + "text": "⑤ 통보 경로가 비어 있으면 이 시스템은 감지 기록 장치일 뿐입니다. 감지되어도 화면을 보고 있는 사람에게만 전달됩니다." + } + }, + { + "heading": "12. 통보 경로 설정", + "shot": "admin.jpg", + "body": [ + "현장 경광등을 가장 먼저 설정하세요. 라즈베리파이가 직접 울리므로 인터넷이나 서버가 끊겨도 동작하는 유일한 경로입니다. 정전·회선 장애는 오히려 위험이 높아지는 때입니다.", + "메일·웹훅은 그 위에 얹는 보조 수단이며, 서버의 자동 실행(크론)이 등록되어 있어야 나갑니다.", + "단계를 1 이상으로 두면 에스컬레이션 채널이 됩니다. 사건이 정해진 시간 동안 확인되지 않을 때만 울리므로, 상급자에게 가는 경로로 씁니다.", + "알림에는 현장 사진이 들어가지 않습니다. 링크만 보내고, 사진은 로그인한 사람만 볼 수 있습니다." + ], + "callout": { + "kind": "warn", + "text": "채널을 처음 켤 때는 반드시 시험 발송(--dry-run)부터 하세요. 잘못된 수신자로 첫 발송을 하면 그 현장에서는 두 번 다시 이 기능을 켜지 않게 됩니다. 방법은 설치 안내서(README-UPLOAD.md) 8번에 있습니다." + } + }, + { + "heading": "13. 감지 임계값 조정", + "body": [ + "같은 [시스템 설정] 화면 아래쪽입니다. 장치를 고르면 조정 가능한 값들이 설명과 함께 나옵니다.", + "값을 바꾸면 그 장치의 설정 버전이 올라가고, 다음 동기화(최대 수 분) 때 적용됩니다. 적용됐는지는 [이벤트] 화면 왼쪽 장치 목록의 설정 지연 표시가 사라지는 것으로 확인합니다.", + "허용 범위를 벗어난 값은 저장되지 않습니다. 장비가 기동할 때 거부하는 값이라 미리 막습니다.", + "언제 조정하나: 특정 카메라의 오탐이 계속되면 [이벤트] 화면 왼쪽 아래 오탐율 패널을 먼저 보세요. 30% 이상인 카메라·유형이 재조정 후보입니다.", + "모든 변경은 누가 언제 바꿨는지 기록에 남습니다." + ] + }, + { + "heading": "14. 정기 점검 항목", + "body": [ + "매일: [이벤트] 화면 왼쪽 장치 목록이 전부 초록인지. 빨간 장치는 감시 공백입니다.", + "매주: [안전 리포트]를 인쇄해 회의 자료로. 오탐율이 오르고 있으면 임계값 조정을 검토합니다.", + "매월: 저장 공간 경고가 뜨지 않는지. 뜨면 사진 보존기간을 줄이거나 용량을 늘려야 합니다 — 그대로 두면 새 사건의 증거 사진이 저장되지 않습니다.", + "분기: 백업(tools/backup.php)을 받아 안전한 곳에 보관. 감사 기록 무결성 검증(tools/verify_audit_chain.php)도 함께 실행합니다." + ] + } + ] + } + ], + "faq": [ + { + "q": "화면이 조용한데 정상인가요?", + "a": "왼쪽 장치 목록이 전부 초록이면 정상입니다. 빨간 장치가 있으면 그 카메라는 아무것도 보지 못하고 있는 것이므로, 조용한 것이 아니라 안 보이는 것입니다." + }, + { + "q": "알림이 안 옵니다.", + "a": "① /setup 화면의 ⑤ 통보 경로에 체크가 켜져 있는지 ② 자동 실행(크론)이 등록되어 있는지 ③ 채널의 '최소 심각도'가 너무 높지 않은지 순서로 확인하세요. ②가 가장 흔한 원인입니다." + }, + { + "q": "잘못 확인 처리했습니다. 되돌릴 수 있나요?", + "a": "화면에서는 되돌릴 수 없습니다. 안전 기록을 조용히 되돌릴 수 있으면 그 기록을 근거로 쓸 수 없기 때문입니다. 잘못 처리한 사건은 그대로 두고, 필요하면 개선 조치 기록에 경위를 남기세요." + }, + { + "q": "오탐이 너무 많습니다.", + "a": "[이벤트] 화면 왼쪽 아래 오탐율 패널에서 어떤 카메라·유형이 문제인지 먼저 확인하세요. 표본이 적으면 비율 대신 원시 숫자만 표시됩니다. 30% 이상인 항목이 조정 대상이고, 조정은 [시스템 설정]의 감지 임계값에서 합니다." + }, + { + "q": "작업자 얼굴이 찍히는 게 걱정됩니다.", + "a": "얼굴은 촬영하는 순간 흐리게 처리되어 저장됩니다. 원본은 어디에도 남지 않습니다. 사진은 로그인한 사람만 볼 수 있고, 정해진 보존기간이 지나면 자동으로 삭제됩니다. 알림 메일에는 사진이 들어가지 않습니다." + }, + { + "q": "정비 중인데 알람이 계속 울립니다.", + "a": "[안전 관리] → 정비 중 알람 억제에서 대상과 기간, 사유를 넣으세요. 알림만 멈추고 감지·기록은 계속되므로 나중에 그 시간대를 조사할 수 있습니다." + } + ] +} diff --git a/server/deploy/manual/shots/admin.jpg b/server/deploy/manual/shots/admin.jpg new file mode 100644 index 0000000..8608093 Binary files /dev/null and b/server/deploy/manual/shots/admin.jpg differ diff --git a/server/deploy/manual/shots/compliance.jpg b/server/deploy/manual/shots/compliance.jpg new file mode 100644 index 0000000..f82cfe1 Binary files /dev/null and b/server/deploy/manual/shots/compliance.jpg differ diff --git a/server/deploy/manual/shots/event-detail.jpg b/server/deploy/manual/shots/event-detail.jpg new file mode 100644 index 0000000..7561d73 Binary files /dev/null and b/server/deploy/manual/shots/event-detail.jpg differ diff --git a/server/deploy/manual/shots/events.jpg b/server/deploy/manual/shots/events.jpg new file mode 100644 index 0000000..9bdaf50 Binary files /dev/null and b/server/deploy/manual/shots/events.jpg differ diff --git a/server/deploy/manual/shots/package.jpg b/server/deploy/manual/shots/package.jpg new file mode 100644 index 0000000..74ac55c Binary files /dev/null and b/server/deploy/manual/shots/package.jpg differ diff --git a/server/deploy/manual/shots/report.jpg b/server/deploy/manual/shots/report.jpg new file mode 100644 index 0000000..f3167c1 Binary files /dev/null and b/server/deploy/manual/shots/report.jpg differ diff --git a/server/deploy/manual/shots/setup.jpg b/server/deploy/manual/shots/setup.jpg new file mode 100644 index 0000000..3268c99 Binary files /dev/null and b/server/deploy/manual/shots/setup.jpg differ diff --git a/server/deploy/manual/shots/zones.jpg b/server/deploy/manual/shots/zones.jpg new file mode 100644 index 0000000..f86a8ba Binary files /dev/null and b/server/deploy/manual/shots/zones.jpg differ diff --git a/tests/test_deploy_artifacts.py b/tests/test_deploy_artifacts.py index 8e4a11a..0187f5b 100644 --- a/tests/test_deploy_artifacts.py +++ b/tests/test_deploy_artifacts.py @@ -322,3 +322,37 @@ def test_offline_installer_verifies_integrity_before_install(): assert verify_at < sh.index("install --no-index") # before installing wheels # A missing SHA256SUMS must be FATAL (refuse), not a warn-and-proceed that installs blind. assert "refusing to install unverified code" in sh + + +# --------------------------------------------------------------------------- +# 2026-08-18: 셸 스크립트 줄바꿈/문법 가드. +# Windows 빌드머신에서 편집하다 CRLF 가 섞이면 bash 가 `{\r` 에서 죽는다 — 그러면 납품 +# 산출물을 아예 만들 수 없다. cafe24 빌드 스크립트에는 이미 같은 가드가 있었고, Pi 쪽에는 +# 없어서 실제로 이 사고가 났다. +# --------------------------------------------------------------------------- + +import subprocess +SHELL_SCRIPTS = [SETUP_ONLINE, INSTALL_OFFLINE, BUILD_OFFLINE] +REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +# Git Bash 는 Windows 백슬래시 경로를 받으면 구분자를 먹어버린다. 저장소 상대 POSIX 경로를 +# cwd=REPO_ROOT 로 넘긴다 (test_cafe24_package.py 가 이미 쓰는 방식). +SHELL_SCRIPTS_REL = [ + "deploy/setup_online.sh", + "deploy/bundle/install_offline.sh", + "deploy/bundle/build_offline_bundle.sh", +] + + +def test_shell_scripts_are_lf_only(): + for path in SHELL_SCRIPTS: + raw = open(path, "rb").read() + assert bytes([13, 10]) not in raw, ( + f"{os.path.basename(path)} 에 CRLF 가 있습니다. bash 가 여는 중괄호에서 죽습니다 — " + "LF 로 저장하세요." + ) + + +def test_shell_scripts_are_syntactically_valid_bash(): + for rel in SHELL_SCRIPTS_REL: + r = subprocess.run(["bash", "-n", rel], cwd=REPO_ROOT, capture_output=True, text=True) + assert r.returncode == 0, "bash -n " + rel + " 실패:" + chr(10) + r.stderr