From 0a1e6225465be5eb502b79f35fe07a98df362345 Mon Sep 17 00:00:00 2001 From: how2ice Date: Mon, 25 May 2026 15:33:30 +0900 Subject: [PATCH] hotfix: guard staged asset commits --- .githooks/pre-commit | 10 ++++ .gitignore | 8 +++ AGENTS.md | 24 +++++++-- README.md | 6 ++- scripts/guard-staged-assets.mjs | 95 +++++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 6 deletions(-) create mode 100755 .githooks/pre-commit create mode 100755 scripts/guard-staged-assets.mjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..c3a69d8 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v node >/dev/null 2>&1; then + if [ -s "$HOME/.nvm/nvm.sh" ]; then + . "$HOME/.nvm/nvm.sh" + fi +fi + +node scripts/guard-staged-assets.mjs diff --git a/.gitignore b/.gitignore index e8ea3a8..8eeaf60 100755 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,14 @@ node_modules.root-owned-backup/ .env.* .tmp !.env.example +tmp-*.png +tmp-*.jpg +tmp-*.jpeg +tmp-*.webp +tmp-*.gif +tmp-*.mp4 +tmp-*.mov +tmp-*.webm # etc workspace etc/**/.env diff --git a/AGENTS.md b/AGENTS.md index 5a84f91..f5d5733 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ ### Codex / AI 기본 규칙 * 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선을 요청하면 **현재 로컬 `main`에서 바로 작업**한다 -* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다 -* 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env`의 `CAPTURE_BASE_URL=https://test.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다 +* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://preview.sm-home.cloud/` 기준으로 사용**한다 +* 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env`의 `CAPTURE_BASE_URL=https://preview.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다 * 별도로 운영 중인 `4173` 포트의 `ai-code-app-preview` 컨테이너는 **채팅 전용 테스트 서버가 아니라 현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 해석한다 * `test.sm-home.cloud` nginx 프록시는 **화면 `/`만 `5174` 앱 테스트 서버로 보내고, `/api/`와 `/ws/chat`은 항상 `127.0.0.1:3100` work-server로 유지**한다 * `test.sm-home.cloud`의 `/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다 @@ -18,9 +18,13 @@ * `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다 * `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다 * 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다 +* Git 작업을 수행하더라도 `public/assets/` 아래 대용량 리소스, `tmp-*` 캡처 파일, 임시 산출물은 기본적으로 커밋 대상에 포함하지 않는다 +* 이미지/동영상/PDF 같은 바이너리 자산이 정말 필요할 때만 예외적으로 커밋하고, 그 외에는 코드 변경과 분리해 별도 확인 후 처리한다 * 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다 * 현재는 브랜치 전략보다 **로컬 실행 가능 상태 유지, 코드 수정, 문서 갱신, 메모 반영 속도**를 우선한다 * `.auto_codex` 관련 Git 자동화, Plan 자동화, 브랜치 생성/병합 흐름은 사용자가 다시 요청하기 전까지 **비활성 상태**로 취급한다 +* 사용자가 **명시적으로 요청한 경우를 제외하면** 구현 편의나 상태 갱신을 이유로 `polling`, `setInterval`, 주기적 재시도 루프 같은 반복 조회 구조를 추가하거나 유지하지 않는다 +* 기존 기능에 `polling`, `setInterval`이 이미 있더라도 사용자 요청 없이 당연한 전제로 유지하지 말고, 이벤트 기반·명시적 새로고침·단발성 요청으로 대체 가능한지 먼저 검토한다 ### 요청 해석 규칙 @@ -40,6 +44,7 @@ * 로컬 작업 중에도 사용자가 요청하지 않은 Git 정리 작업은 하지 않는다 * `reset`, `checkout`, `switch`, `clean`, 강제 덮어쓰기처럼 되돌리기 어려운 작업은 자동으로 수행하지 않는다 * 사용자가 Git 작업을 요청해도 **정말 필요한 범위만** 실행한다 +* 임시 스크린샷, 테스트 캡처, 대용량 리소스 파일은 기본적으로 Git 커밋을 차단하고, 의도적 자산 커밋일 때만 명시적으로 예외 처리한다 * 현재 모드에서는 `main` 직접 수정이 허용되지만, **자동 commit / push는 여전히 금지**한다 --- @@ -47,7 +52,8 @@ ## Codex Live / 채팅 / 작업 메모 규칙 * `Codex Live`, 일반 채팅 요청은 현재 프로젝트의 로컬 `main`을 기준으로 처리한다 -* 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다 +* 외부 도메인 기준 동작 확인과 최종 검증은 기본적으로 `https://preview.sm-home.cloud/`를 우선 기준으로 본다 +* `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인한다 * `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다 * 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다 * 일반 작업 메모는 기록 목적이면 메모로 유지하고, 실제 수정 지시라도 별도 자동화 접수가 아니면 `main` 기준 로컬 작업으로 연결한다 @@ -57,11 +63,21 @@ * 채팅 답변에서 링크 카드는 외부 공개 링크에만 사용하고 `[[link-card:제목|URL|버튼라벨]]` 형식을 정확히 지킨다 * 세션 리소스, 내부 문서, 코드, 로그, 테이블 파일처럼 `/api/chat/resources/...`, `/.codex_chat/...`, `/public/.codex_chat/...` 아래 내부 리소스에는 링크 카드를 사용하지 않는다 * 링크 카드 URL 안에 구분자 `|`를 `%7C`로 인코딩하거나 `https:/api/...`처럼 잘못 줄여 쓰지 않는다 +* `[[prompt:...]]` 내부의 `preview`, preview card 성격의 미리보기는 실제로 생성과 접근이 확인된 리소스에만 연결한다 +* `[[prompt:...]]`의 `preview.url`에는 로컬 파일 시스템 경로(`/home/...`, `C:\\...`)나 원본 `public/...` 경로를 직접 넣지 말고, 외부 `https://...` URL 또는 `/api/chat/resources/...`, `resource/...`, `/api/resource-manager/preview/...`처럼 실제 미리보기 가능한 경로만 사용한다 +* `[[prompt:...]]`에서 내부 문서/HTML/표/산출물을 미리 보여줄 때는 링크 카드로 우회하지 말고 `preview.type:"resource"` 또는 용도에 맞는 preview 타입을 우선 사용한다 +* `[[prompt:...]]`는 본문 문장 사이에 들어가더라도 prompt 컴포넌트로 안정적으로 파싱되어야 하며, HTML/문서 preview가 있는 prompt는 raw 텍스트로 남지 않게 확인한다 +* prompt 안의 preview 리소스가 열리지 않을 때는 곧바로 `리소스를 못 찾겠다`고 답하지 말고, 먼저 파일 생성 여부, 세션 리소스 복사 여부, `preview.url` 문법, preview 타입 선택이 맞는지 순서대로 다시 확인한다 +* 사용자가 `전체 케이스`, `모든 케이스`, `전수 검증`을 요청하면 실제 분기 함수를 직접 확인하고 `null` 반환, 기본값, 우선순위 예외까지 빠짐없이 표나 목록으로 정리한 뒤 검증했다고 답한다 +* 전수 검증 요청에서 대표 예시 몇 개만 적거나 상태 enum 이름만 나열한 뒤 `전체 케이스`라고 단정하지 않는다 +* HTML 미리보기 산출물은 fallback 안내문이나 앱 화면 URL로 대신하지 말고, 실제 `.html` 리소스를 만든 뒤 그 리소스가 열리는지 다시 확인하고 제공한다 * 모바일 캡처 결과나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 형식으로 제공한다 * 모바일 캡처 결과를 설명하는 본문 옆에 링크 카드를 덧붙이더라도, 같은 리소스의 `[[preview:URL]]` 표시는 생략하지 않는다 * 내부 문서성 리소스는 일반 경로나 자동 프리뷰 가능한 리소스 URL로 제공하고, 표 형태 확인이 필요하면 preview 컴포넌트에서 바로 열 수 있는 형식을 우선 사용한다 +* markdown/document preview를 최대화해서 볼 때는 본문 배경과 텍스트 대비가 유지되도록 확인하고, 다크 배경 위에 검정 본문만 남는 상태로 두지 않는다 * 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다 * 관리/등록 화면 UI를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다 +* `/play/apps` 아래에서 실행되는 앱 화면은 기본적으로 부모 앱 헤더를 다시 노출하지 말고, 개별 앱 콘텐츠가 화면을 가득 채우는 레이아웃을 우선 적용한다 --- @@ -76,7 +92,7 @@ ## 한 줄 요약 👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다 -👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나다 +👉 외부 확인과 검증 기본 도메인은 `https://preview.sm-home.cloud/`다 👉 자동화 브랜치 흐름은 문서에 고정 규칙으로 적지 않는다 👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다 diff --git a/README.md b/README.md index 9a92a9b..94cf1aa 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입 - `채팅`, `Codex Live`, 일반 `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다. - 자동화 접수된 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다. - 채팅 리소스와 첨부 파일은 `public/.codex_chat//resource/...` 기준으로 제공하며, 업로드 파일은 `public/.codex_chat//resource/uploads/...` 아래를 사용합니다. +- `public/assets/` 아래 리소스, `tmp-*` 캡처 파일, 대용량 바이너리 파일은 기본적으로 커밋하지 않습니다. +- 저장소에는 staged 자산 차단 훅이 연결되어 있으며, 의도적인 자산 커밋이 꼭 필요할 때만 `ALLOW_ASSET_COMMIT=1 git commit ...`으로 예외 처리합니다. ## 시작하기 @@ -37,8 +39,8 @@ docker compose -f docker-compose.preview.yml up -d --build - 테스트용 컨테이너는 현재 `ai-code-app-preview` 하나만 유지합니다. - 이 컨테이너는 채팅 전용 테스트가 아니라 **현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 사용합니다. -- 운영 프록시 확인은 `https://test.sm-home.cloud/` 기준으로 유지합니다. -- 소스 변경 검증과 최종 화면 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다. +- 화면 테스트, 소스 변경 검증, 최종 화면 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다. +- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다. - 위 프록시 기준은 임의로 바꾸지 말고, 변경이 필요하면 반드시 운영 목적을 먼저 문서에 남깁니다. - 임시 테스트 컨테이너를 추가로 띄우지 말고, 필요 시 기존 `ai-code-app-preview`만 재기동합니다. - 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다. diff --git a/scripts/guard-staged-assets.mjs b/scripts/guard-staged-assets.mjs new file mode 100755 index 0000000..0be45fd --- /dev/null +++ b/scripts/guard-staged-assets.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { statSync } from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const allowAssetCommit = process.env.ALLOW_ASSET_COMMIT === '1'; +const maxBinarySizeBytes = 5 * 1024 * 1024; +const tmpCapturePattern = /^tmp-.*\.(png|jpe?g|webp|gif|mp4|mov|webm)$/i; +const binaryAssetPattern = /\.(png|jpe?g|webp|gif|svg|mp4|mov|webm|pdf|zip|7z)$/i; + +const blockedAssetPrefixes = ['public/assets/']; + +function getStagedPaths() { + const output = execFileSync( + 'git', + ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], + { cwd: repoRoot, encoding: 'utf8' } + ); + + return output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function toDisplaySize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function isBlockedAssetPath(filePath) { + return blockedAssetPrefixes.some((prefix) => filePath.startsWith(prefix)); +} + +function main() { + const stagedPaths = getStagedPaths(); + const violations = []; + + for (const relativePath of stagedPaths) { + const absolutePath = path.join(repoRoot, relativePath); + let stats; + + try { + stats = statSync(absolutePath); + } catch { + continue; + } + + if (!stats.isFile()) continue; + + const baseName = path.basename(relativePath); + const isTmpCapture = tmpCapturePattern.test(baseName); + const isBinaryAsset = binaryAssetPattern.test(relativePath); + const isOversizedBinary = isBinaryAsset && stats.size > maxBinarySizeBytes; + const isBlockedAsset = isBinaryAsset && isBlockedAssetPath(relativePath); + + if (!isTmpCapture && !isOversizedBinary && !isBlockedAsset) continue; + + const reasons = []; + if (isTmpCapture) reasons.push('temporary capture file'); + if (isBlockedAsset) reasons.push('asset path under public/assets'); + if (isOversizedBinary) { + reasons.push(`binary file exceeds ${toDisplaySize(maxBinarySizeBytes)}`); + } + + violations.push({ + relativePath, + size: stats.size, + reasons, + }); + } + + if (violations.length === 0 || allowAssetCommit) { + return; + } + + console.error(''); + console.error('Blocked commit: staged asset files need an explicit override.'); + console.error('Set ALLOW_ASSET_COMMIT=1 only when the asset commit is intentional.'); + console.error(''); + + for (const violation of violations) { + console.error( + `- ${violation.relativePath} (${toDisplaySize(violation.size)}): ${violation.reasons.join(', ')}` + ); + } + + console.error(''); + process.exit(1); +} + +main();