HAL 0.3.10: 텍스트 셸 복사 대응 — 출력에서 인증 주소 자동 추출(줄 끝에서 끊긴 긴 주소 이어 붙임, 스크롤백·대체 화면 포함)과 "감지된 주소" 복사·열기 단추, 단말 드래그 선택 시 Ctrl+C 는 복사(Ctrl+Shift+C 항상 복사, 선택 없을 때만 셸로 전송), 전체 출력 복사·선택 복사·텍스트로 보기, 클립보드 폴백, 셸 모달 순수 템플릿화, 개발 지시 v0.3.10(Oz)·docs/HAL-AI.md §9·VERSION_HISTORY 0.3.10 — test/hal-ai-0310.test.mjs RED 0/4 → GREEN 4/4, 전체 29파일 199건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.3.10
|
||||||
|
|
||||||
|
**작성자:** Oz
|
||||||
|
**작성일:** 2026-10-29
|
||||||
|
**이전 버전:** v0.3.9(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz) · v0.3.2 · v0.3.1 · v0.3.0 · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
|
||||||
|
**이번 개정:** 텍스트 셸 복사 대응. HAL 0.3.9 → **0.3.10**. HAL 에만 적용하며 이전 지시의 모든 기준은 그대로다.
|
||||||
|
|
||||||
|
## 1. 사용자 요구(Oz 원문, 약화 금지)
|
||||||
|
text shell 카피 안 됨. 텍스트 셸에서 브라우저를 열고 인증 후 프롬프트를 입력하라고 나오는데, 긴 브라우저 접속 주소를 카피할 수 없다. 이런 상황들에 대한 대응책을 마련하라.
|
||||||
|
|
||||||
|
## 2. 원인(구현 전 확인)
|
||||||
|
- 0.3.9 단말 영역은 포커스 상태에서 모든 키를 셸로 보내 Ctrl+C 가 복사가 아니라 중단 신호(^C)로 갔다.
|
||||||
|
- 긴 주소가 단말 열 수에서 끊겨 두 줄로 그려지고, 복사·열기 단추가 없었다.
|
||||||
|
|
||||||
|
## 3. 필수 동작(계약)
|
||||||
|
1. 셸 출력에서 주소(https://…)를 자동으로 찾아 "감지된 주소" 목록에 보이고, 각 주소에 복사·열기(새 탭, noopener) 단추를 둔다. 줄 끝에서 끊긴 긴 주소는 이어 붙인다(스크롤백·대체 화면 포함).
|
||||||
|
2. 단말 글을 드래그로 선택할 수 있고, 선택이 있으면 Ctrl+C 는 복사, Ctrl+Shift+C·Ctrl+Insert 는 항상 복사, Ctrl+V 는 붙여넣기. 선택이 없을 때만 Ctrl+C 가 셸로 간다.
|
||||||
|
3. "전체 출력 복사", "선택 복사", "텍스트로 보기"(선택 가능한 텍스트 상자) 제공. 복사는 Clipboard API 가 막히면 폴백을 쓰고 실패를 안내한다.
|
||||||
|
4. 주소는 화면·클립보드로만 가고 서버에 저장하지 않는다.
|
||||||
|
|
||||||
|
## 4. TDD·검증
|
||||||
|
- `test/hal-ai-0310.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현: URL 추출(이어 붙이기·스크롤백·상자 문자·구두점·중복·대체 화면·전체 글), 복사 판정, 셸 모달 순수 템플릿(주소 목록·복사·텍스트 보기·안내·이스케이프), 화면·버전·문서. 기존 AI 테스트 유지, 전체 Suite, 배포 뒤 read-back.
|
||||||
|
|
||||||
|
## 5. 버전·배포
|
||||||
|
- HAL 0.3.10: package.json·README·VERSION_HISTORY 0.3.10 절·docs/HAL-AI.md §9. 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 만 배포 → read-back.
|
||||||
|
|
||||||
|
## 6. 완료 조건
|
||||||
|
원인, 변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back, 알려진 제한을 보고한다.
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
||||||
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
||||||
|
|
||||||
- 버전: **0.3.9** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
- 버전: **0.3.10** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
||||||
- HAL AI 채팅룸(연결·방·도구·MCP, 0.3.0): [docs/HAL-AI.md](docs/HAL-AI.md)
|
- HAL AI 채팅룸(연결·방·도구·MCP, 0.3.0): [docs/HAL-AI.md](docs/HAL-AI.md)
|
||||||
- 설계·개발 규약: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · 포털↔HAL 작업 계약: [docs/HAL-JOBS.md](docs/HAL-JOBS.md) · HAL↔Agent 프로토콜: [docs/HAL-AGENT.md](docs/HAL-AGENT.md)
|
- 설계·개발 규약: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · 포털↔HAL 작업 계약: [docs/HAL-JOBS.md](docs/HAL-JOBS.md) · HAL↔Agent 프로토콜: [docs/HAL-AGENT.md](docs/HAL-AGENT.md)
|
||||||
- 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`)
|
- 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`)
|
||||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.1.md`(서버 설치 CLI 자동 탐색, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.2.md`(AI 연동 편의성·도구 기본 설정·승인 요청, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.9.md`(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz)
|
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.1.md`(서버 설치 CLI 자동 탐색, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.2.md`(AI 연동 편의성·도구 기본 설정·승인 요청, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.9.md`(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.10.md`(텍스트 셸 복사·주소 추출, Oz)
|
||||||
|
|
||||||
## 구성
|
## 구성
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
# VERSION_HISTORY
|
# VERSION_HISTORY
|
||||||
|
|
||||||
|
## 0.3.10 (2026-10-29)
|
||||||
|
|
||||||
|
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.10.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-shell-copy-v0.3.10`, 기준 main `bed038f` = 0.3.9). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
|
||||||
|
- **이전 버전**: 0.3.9(2026-10-29). 끝 번호 1 증가(결함 대응 1항목). HAL 에만 적용, 다른 모듈 변경 없음(배포하지 않음).
|
||||||
|
|
||||||
|
### 요구(사용자 요구 — 약화 없이 반영)
|
||||||
|
텍스트 셸에서 브라우저를 열고 인증 후 프롬프트를 입력하라고 나오는데, 긴 브라우저 접속 주소를 복사할 수 없다. 이런 상황에 대한 대응책을 마련하라.
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
- 0.3.9 단말 영역은 포커스 상태에서 모든 키를 셸로 보냈다 → Ctrl+C 가 복사 대신 ^C(중단)로 전달되어 복사가 되지 않았다.
|
||||||
|
- 긴 인증 주소가 단말 열 수(100)에서 끊겨 두 줄로 그려져 한 번에 고르기 어려웠고, 클릭·복사 단추가 없었다.
|
||||||
|
|
||||||
|
### 수정한 기존 기능
|
||||||
|
- `hal/ui/public/term.mjs`: `joinedLines`(꽉 찬 줄을 다음 줄과 이어 붙임)·`plainText`·`extractUrls`(스크롤백·대체 화면 포함, 상자 문자(│┃║|) 제외, 꼬리 구두점 제거, 중복 제거)·`copyDecision`(선택 있으면 Ctrl+C 복사, Ctrl+Shift+C·Ctrl+Insert 항상 복사, Ctrl+V·Shift+Insert 붙여넣기, 그 밖은 전송).
|
||||||
|
- `hal/ui/public/ai-render.mjs`: `shellModalHtml`(셸 모달 본문을 순수 템플릿으로)·`shellUrlsHtml`(감지된 주소: 복사 단추, 새 탭 열기 `rel="noopener noreferrer"`), 전체 출력 복사·선택 복사·텍스트로 보기(읽기 전용 textarea)·복사 안내. `hal/ui/public/ai.js`: `copyText`(Clipboard API → 숨은 textarea 폴백 → 안내), 키 처리에 `copyDecision`, 폴링마다 주소 목록·텍스트 보기 갱신. `ops.css`: 단말 `user-select:text`, 주소 목록 스타일. `hal/ui.mjs`: 셸 절에 복사 방법 안내.
|
||||||
|
- 버전·문서: 0.3.10(package.json·README·버전 검사 테스트), `docs/HAL-AI.md` §9, 개발 지시 v0.3.10.
|
||||||
|
|
||||||
|
### 고객 화면
|
||||||
|
- 변경 없음.
|
||||||
|
|
||||||
|
### 운영자 화면
|
||||||
|
- 텍스트 셸 모달에 "감지된 주소" 목록(복사·열기), "전체 출력 복사", "선택 복사", "텍스트로 보기". 단말 글을 드래그로 선택하면 Ctrl+C 가 복사가 되고(Ctrl+Shift+C 는 항상 복사), 셸에 중단 신호를 보내려면 선택을 풀고 Ctrl+C 또는 키 단추.
|
||||||
|
|
||||||
|
### 보안 결정
|
||||||
|
- 열기 링크는 새 탭 + `noopener noreferrer`(HAL 창 접근 차단). 주소는 화면 표시·클립보드로만 가고 서버에 저장하지 않는다. 셸 입력·출력 저장 정책은 0.3.2 그대로(입력 미저장, 출력 메모리만).
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- RED(구현 전): 새 `test/hal-ai-0310.test.mjs` **0/4**(`extractUrls`·`copyDecision`·`shellUrlsHtml` 없음, 화면 안내 없음).
|
||||||
|
- GREEN: `test/hal-ai-0310.test.mjs` **4/4** — 줄 끝에서 끊긴 주소 이어 붙이기(40열)·꽉 차지 않은 줄은 잇지 않음·꼬리 마침표 제거·상자 문자 제외·스크롤백·중복 제거·대체 화면·전체 글 / 복사 판정(선택 Ctrl+C=복사, 없으면 전송, Ctrl+Shift+C·Ctrl+Insert=복사, Ctrl+V·Shift+Insert=붙여넣기) / 셸 모달 순수 템플릿(주소 목록 이스케이프·복사·열기 noopener·전체 복사·텍스트 보기·안내) / 화면·버전·문서.
|
||||||
|
- 기존: `hal-ai-039` 6/6 · `hal-ai-032` 6/6 · `hal-ai` 12/12 · `hal-ui` 7/7 · `webmaker-visual-editor` 5/5. 전체 Suite `node test/run.mjs`: **29개 파일 실패 0, 199건 통과**. 변경 파일 secret-like 스캔 0건. 러너 임시 파일 미커밋.
|
||||||
|
|
||||||
|
### 배포(bear `hal21.ariweb.net`, HAL 만)
|
||||||
|
- 절차와 read-back 은 배포 뒤 이 절에 후속 커밋으로 기록한다.
|
||||||
|
|
||||||
|
### 알려진 제한
|
||||||
|
- 주소가 두 줄 이상에 걸쳐 끊길 때 중간 줄이 꽉 차지 않으면(TUI 가 상자 안에서 임의로 줄을 나눈 경우) 이어 붙이지 못할 수 있다 — 그때는 "텍스트로 보기"에서 직접 고른다.
|
||||||
|
- Clipboard API 는 HTTPS·사용자 동작 안에서만 동작한다(hal21 은 HTTPS). 실패 시 폴백·안내.
|
||||||
|
|
||||||
|
### 미확정 정책값
|
||||||
|
- 없음.
|
||||||
|
|
||||||
## 0.3.9 (2026-10-29)
|
## 0.3.9 (2026-10-29)
|
||||||
|
|
||||||
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.9.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-ai-shell-v0.3.9`, 기준 main `f861c7e` = 0.3.2). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
|
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.9.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-ai-shell-v0.3.9`, 기준 main `f861c7e` = 0.3.2). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
|
||||||
|
|||||||
@@ -72,3 +72,8 @@ HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다. 읽기 도구만
|
|||||||
- **모델 선택**: 연결 표와 패널 상태 줄의 모델 선택 상자(확인된 목록) → `PATCH /connections/:id {model}`(목록 밖 거부). `/model <이름>` 명령(개별 방). `GET /rooms` 의 `connection.models`.
|
- **모델 선택**: 연결 표와 패널 상태 줄의 모델 선택 상자(확인된 목록) → `PATCH /connections/:id {model}`(목록 밖 거부). `/model <이름>` 명령(개별 방). `GET /rooms` 의 `connection.models`.
|
||||||
- **슬래시 명령**(`hal/ai/commands.mjs`, `GET /ops/api/ai/commands`): local — `/help` `/tools` `/model` `/reset`(모델 호출 없음) / prompt — `/status` `/health` `/servers [역할]` `/server <id>` `/jobs [상태]` `/job <id>` `/releases` `/incidents` `/audit [접두어]` `/dr` `/emergency` `/order <지시>` `/deploy <모듈> <버전> [서버]` `/rollback …` `/isolate <서버> <사유>` `/incident <key> <제목>`. prompt 명령은 운영자 원문을 기록에 남기고 모델에게는 확장 지시(`content[].type=command.expanded`)를 보낸다. 확장 지시는 승인 규칙을 바꾸지 않는다(쓰기·고위험은 제안). 모르는 명령 400 `unknown_command`, 인자 누락 400 `command_args_required`, 통합 방 `/model` 400 `command_needs_direct_room`.
|
- **슬래시 명령**(`hal/ai/commands.mjs`, `GET /ops/api/ai/commands`): local — `/help` `/tools` `/model` `/reset`(모델 호출 없음) / prompt — `/status` `/health` `/servers [역할]` `/server <id>` `/jobs [상태]` `/job <id>` `/releases` `/incidents` `/audit [접두어]` `/dr` `/emergency` `/order <지시>` `/deploy <모듈> <버전> [서버]` `/rollback …` `/isolate <서버> <사유>` `/incident <key> <제목>`. prompt 명령은 운영자 원문을 기록에 남기고 모델에게는 확장 지시(`content[].type=command.expanded`)를 보낸다. 확장 지시는 승인 규칙을 바꾸지 않는다(쓰기·고위험은 제안). 모르는 명령 400 `unknown_command`, 인자 누락 400 `command_args_required`, 통합 방 `/model` 400 `command_needs_direct_room`.
|
||||||
- 점검 수정: 패널 상태 줄에 `reauth_required` 연결용 "로그인·재인증" 단추(OAuth → 로그인 창, CLI → 로그인 셸), `window.open` 기능 문자열 정리.
|
- 점검 수정: 패널 상태 줄에 `reauth_required` 연결용 "로그인·재인증" 단추(OAuth → 로그인 창, CLI → 로그인 셸), `window.open` 기능 문자열 정리.
|
||||||
|
|
||||||
|
## 9. 0.3.10 — 텍스트 셸 복사·주소 추출
|
||||||
|
- 원인: 단말 영역이 모든 키를 셸로 보내 Ctrl+C 가 복사 대신 ^C 가 되었고, 긴 인증 주소가 줄 끝(열 수)에서 끊겨 두 줄로 보여 통째로 고를 수 없었다.
|
||||||
|
- `term.mjs`: `joinedLines`(꽉 찬 줄은 다음 줄과 이어 붙임) · `plainText` · `extractUrls`(스크롤백·대체 화면 포함, 상자 문자 제외, 꼬리 구두점 제거, 중복 제거) · `copyDecision`(선택이 있으면 Ctrl+C=복사, Ctrl+Shift+C·Ctrl+Insert=항상 복사, Ctrl+V/Shift+Insert=붙여넣기, 나머지=전송).
|
||||||
|
- 화면(`ai-render.shellModalHtml`·`shellUrlsHtml`, `ai.js`): "감지된 주소" 목록(복사·열기 — 새 탭, noopener), "전체 출력 복사", "선택 복사", "텍스트로 보기"(선택 가능한 textarea), 단말 드래그 선택 가능(`user-select:text`). 복사는 Clipboard API → 실패 시 숨은 textarea+execCommand → 안내.
|
||||||
|
|||||||
+1
-1
@@ -114,7 +114,7 @@ function aiPage(d) {
|
|||||||
<fieldset><legend>도구 정책(기본: 전부 — 읽기는 즉시, 쓰기·고위험은 승인)</legend><label>프리셋<select name="toolsPreset"><option value="all">전부(기본값)</option><option value="read_write">읽기 + 쓰기(고위험 제외)</option><option value="read_only">읽기만</option><option value="custom">직접 선택</option><option value="none">도구 없음(대화만)</option></select></label><div id="ai-tool-policy" class="small" hidden></div><p class="muted small">HAL 운영 도구는 연결 즉시 쓸 수 있게 미리 설정되어 있습니다. 인증·승인이 필요한 도구(쓰기·고위험)는 채팅룸에 승인 요청으로 올라오고, 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다.</p></fieldset>
|
<fieldset><legend>도구 정책(기본: 전부 — 읽기는 즉시, 쓰기·고위험은 승인)</legend><label>프리셋<select name="toolsPreset"><option value="all">전부(기본값)</option><option value="read_write">읽기 + 쓰기(고위험 제외)</option><option value="read_only">읽기만</option><option value="custom">직접 선택</option><option value="none">도구 없음(대화만)</option></select></label><div id="ai-tool-policy" class="small" hidden></div><p class="muted small">HAL 운영 도구는 연결 즉시 쓸 수 있게 미리 설정되어 있습니다. 인증·승인이 필요한 도구(쓰기·고위험)는 채팅룸에 승인 요청으로 올라오고, 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다.</p></fieldset>
|
||||||
<fieldset><legend>고유 정보</legend><div class="row"><label>역할·업무<input name="role" maxlength="2000" placeholder="예: 릴리스 검토·배포 감시"></label><label>특성<input name="traits" maxlength="2000" placeholder="예: 꼼꼼함, 근거 우선"></label></div><label>추가 지시<textarea name="instructions" rows="2" maxlength="2000" placeholder="이 모델에게만 주는 지시"></textarea></label></fieldset>
|
<fieldset><legend>고유 정보</legend><div class="row"><label>역할·업무<input name="role" maxlength="2000" placeholder="예: 릴리스 검토·배포 감시"></label><label>특성<input name="traits" maxlength="2000" placeholder="예: 꼼꼼함, 근거 우선"></label></div><label>추가 지시<textarea name="instructions" rows="2" maxlength="2000" placeholder="이 모델에게만 주는 지시"></textarea></label></fieldset>
|
||||||
<div class="actions"><button class="btn primary" type="submit" id="ai-conn-submit">연결 추가</button><button class="btn" type="button" data-act="ai-conn-cancel" hidden>수정 취소</button></div><p class="err" id="ai-conn-msg" role="alert"></p></form>
|
<div class="actions"><button class="btn primary" type="submit" id="ai-conn-submit">연결 추가</button><button class="btn" type="button" data-act="ai-conn-cancel" hidden>수정 취소</button></div><p class="err" id="ai-conn-msg" role="alert"></p></form>
|
||||||
<h2>텍스트 셸(CLI 로그인·대화형)</h2><p class="muted small">서버 CLI 의 로그인·대화형 화면을 여기서 직접 다룹니다. claude 의 실제 로그인 명령은 <code>claude auth login</code>(화살표·Enter 로 방식을 고르는 TUI)이고, 이미 설치·로그인된 경우 <code>claude</code> 대화형 세션이 "이 폴더를 신뢰합니까?" 같은 질문을 하므로 <b>단말 화면을 클릭해 키를 직접 누르거나</b> 키 단추(↑↓ Enter Esc Ctrl+C y n)로 답합니다. 허용 명령(login·대화형·상태·로그아웃·버전)만 가짜 단말(PTY)로 돌리며 작업 폴더는 HAL 실행 계정 HOME, 입력은 저장하지 않습니다. 시작에는 최근 10분 재인증이 필요합니다.</p><div class="actions"><button class="btn primary" data-act="ai-shell-open" data-tool="claude" data-command="login">claude auth login</button><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="interactive">claude 대화형(신뢰 설정)</button><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="status">claude 상태</button><button class="btn primary" data-act="ai-shell-open" data-tool="codex" data-command="login">codex login</button><button class="btn" data-act="ai-shell-open" data-tool="codex" data-command="interactive">codex 대화형</button><button class="btn" data-act="ai-shell-open" data-tool="codex" data-command="status">codex 상태</button><button class="btn danger" data-act="ai-shell-kill-all">열린 셸 모두 종료</button></div><div id="ai-shells" class="small">${R.statePanel('loading', '셸 세션을 불러오는 중')}</div>
|
<h2>텍스트 셸(CLI 로그인·대화형)</h2><p class="muted small">서버 CLI 의 로그인·대화형 화면을 여기서 직접 다룹니다. claude 의 실제 로그인 명령은 <code>claude auth login</code>(화살표·Enter 로 방식을 고르는 TUI)이고, 이미 설치·로그인된 경우 <code>claude</code> 대화형 세션이 "이 폴더를 신뢰합니까?" 같은 질문을 하므로 <b>단말 화면을 클릭해 키를 직접 누르거나</b> 키 단추(↑↓ Enter Esc Ctrl+C y n)로 답합니다. 허용 명령(login·대화형·상태·로그아웃·버전)만 가짜 단말(PTY)로 돌리며 작업 폴더는 HAL 실행 계정 HOME, 입력은 저장하지 않습니다. 시작에는 최근 10분 재인증이 필요합니다. <b>복사</b>: 셸에 나온 인증 주소는 아래 "감지된 주소" 목록에서 복사·열기 단추로(줄 끝에서 끊긴 긴 주소도 이어 붙임), 단말 글을 드래그 선택하면 Ctrl+C 가 복사가 되고(Ctrl+Shift+C 는 항상 복사), "전체 출력 복사"·"텍스트로 보기"도 있습니다.</p><div class="actions"><button class="btn primary" data-act="ai-shell-open" data-tool="claude" data-command="login">claude auth login</button><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="interactive">claude 대화형(신뢰 설정)</button><button class="btn" data-act="ai-shell-open" data-tool="claude" data-command="status">claude 상태</button><button class="btn primary" data-act="ai-shell-open" data-tool="codex" data-command="login">codex login</button><button class="btn" data-act="ai-shell-open" data-tool="codex" data-command="interactive">codex 대화형</button><button class="btn" data-act="ai-shell-open" data-tool="codex" data-command="status">codex 상태</button><button class="btn danger" data-act="ai-shell-kill-all">열린 셸 모두 종료</button></div><div id="ai-shells" class="small">${R.statePanel('loading', '셸 세션을 불러오는 중')}</div>
|
||||||
<h2>MCP 설정(외부 클라이언트용)</h2><p class="muted small">HAL 도구를 MCP 서버로 쓰는 설정입니다. 로컬 CLI 연결에는 자동으로 붙으며, 운영자의 Claude Code·Codex 에 직접 넣을 수도 있습니다. 쓰기·고위험 도구 호출은 통합 방에 승인 요청으로 올라오고 <code>hal_action_status</code> 로 결과를 봅니다.</p><div id="ai-mcp">${R.statePanel('loading', 'MCP 설정을 불러오는 중')}</div>
|
<h2>MCP 설정(외부 클라이언트용)</h2><p class="muted small">HAL 도구를 MCP 서버로 쓰는 설정입니다. 로컬 CLI 연결에는 자동으로 붙으며, 운영자의 Claude Code·Codex 에 직접 넣을 수도 있습니다. 쓰기·고위험 도구 호출은 통합 방에 승인 요청으로 올라오고 <code>hal_action_status</code> 로 결과를 봅니다.</p><div id="ai-mcp">${R.statePanel('loading', 'MCP 설정을 불러오는 중')}</div>
|
||||||
<h2>AI 가 쓰는 HAL 도구</h2><p class="muted small">읽기 도구는 즉시 실행됩니다. 쓰기·고위험 도구는 채팅룸에 "제안"으로 멈추고 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다. 임의 셸 명령 도구는 없습니다. MCP 클라이언트용: <code>node hal/index.mjs mcp</code>(읽기 전용).</p><div id="ai-tools">${R.statePanel('loading', '도구 목록을 불러오는 중')}</div>`;
|
<h2>AI 가 쓰는 HAL 도구</h2><p class="muted small">읽기 도구는 즉시 실행됩니다. 쓰기·고위험 도구는 채팅룸에 "제안"으로 멈추고 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다. 임의 셸 명령 도구는 없습니다. MCP 클라이언트용: <code>node hal/index.mjs mcp</code>(읽기 전용).</p><div id="ai-tools">${R.statePanel('loading', '도구 목록을 불러오는 중')}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,3 +83,20 @@ export function shellSessionsHtml(sessions) {
|
|||||||
}
|
}
|
||||||
/** [0.3.9] /help 명령 목록 */
|
/** [0.3.9] /help 명령 목록 */
|
||||||
export function commandsHelpHtml(commands) { return `<table class="r"><thead><tr><th>명령</th><th>설명</th></tr></thead><tbody>${(commands || []).map((c) => `<tr><td data-l="명령"><code>${esc(c.usage)}</code></td><td data-l="설명">${esc(c.description)}${c.kind === 'local' ? ' <span class="muted small">(HAL 즉시)</span>' : ''}</td></tr>`).join('')}</tbody></table>`; }
|
export function commandsHelpHtml(commands) { return `<table class="r"><thead><tr><th>명령</th><th>설명</th></tr></thead><tbody>${(commands || []).map((c) => `<tr><td data-l="명령"><code>${esc(c.usage)}</code></td><td data-l="설명">${esc(c.description)}${c.kind === 'local' ? ' <span class="muted small">(HAL 즉시)</span>' : ''}</td></tr>`).join('')}</tbody></table>`; }
|
||||||
|
|
||||||
|
/** [0.3.10] 셸 출력에서 감지된 주소 목록(복사·열기). */
|
||||||
|
export function shellUrlsHtml(urls) {
|
||||||
|
if (!urls || !urls.length) return '<span class="muted small">감지된 주소 없음 — 셸에 https:// 주소가 나오면 여기에 복사·열기 단추가 생깁니다.</span>';
|
||||||
|
return `<ul class="ai-urls">${urls.map((u) => `<li><code class="ai-url">${esc(u)}</code> <button type="button" class="btn small primary" data-act="ai-shell-copy-url" data-url="${esc(u)}">복사</button> <a class="btn small" href="${esc(u)}" target="_blank" rel="noopener noreferrer">열기</a></li>`).join('')}</ul>`;
|
||||||
|
}
|
||||||
|
/** [0.3.10] 텍스트 셸 모달 본문(순수). 단말 영역·키 단추·주소 목록·복사·텍스트 보기·줄 입력. */
|
||||||
|
export function shellModalHtml(session, guide = []) {
|
||||||
|
const keys = [['Enter', '\r'], ['Tab', '\t'], ['Esc', '\u001b'], ['↑', '\u001b[A'], ['↓', '\u001b[B'], ['←', '\u001b[D'], ['→', '\u001b[C'], ['y', 'y'], ['n', 'n'], ['Ctrl+C', '\u0003'], ['Ctrl+D', '\u0004']];
|
||||||
|
return `<h2>텍스트 셸 — ${esc(session.tool)} ${esc(session.command)}</h2>${guide && guide.length ? `<div class="alert info small"><b>안내</b><ol>${guide.map((g) => `<li>${esc(g)}</li>`).join('')}</ol></div>` : ''}<div id="ai-shell-view">${shellHtml(session, '')}</div>
|
||||||
|
<div class="ai-term-wrap"><pre class="ai-term" id="ai-term" tabindex="0" title="클릭한 뒤 키를 누르면 그대로 전달됩니다. 글을 드래그로 선택하면 Ctrl+C 가 복사가 됩니다(Ctrl+Shift+C 는 항상 복사)"></pre></div>
|
||||||
|
<div class="actions ai-keys">${keys.map(([l, b]) => `<button type="button" class="btn small" data-key="${esc(b)}">${esc(l)}</button>`).join('')}</div>
|
||||||
|
<div class="ai-shell-tools"><b class="small">감지된 주소</b> <span class="muted small">(줄 끝에서 끊긴 긴 주소도 이어 붙여 보입니다)</span><div id="ai-shell-urls">${shellUrlsHtml([])}</div>
|
||||||
|
<div class="actions"><button type="button" class="btn small" data-act="ai-shell-copy-all">전체 출력 복사</button><button type="button" class="btn small" data-act="ai-shell-copy-sel">선택 복사</button><button type="button" class="btn small" data-act="ai-shell-text">텍스트로 보기/닫기</button><span class="muted small">단말에서 드래그 선택 뒤 Ctrl+Shift+C 또는 "선택 복사". 셸에 Ctrl+C 를 보내려면 선택을 풀고 누르거나 키 단추를 쓰세요.</span></div>
|
||||||
|
<textarea id="ai-shell-textview" class="ai-shell-textview" rows="10" readonly hidden aria-label="셸 출력 텍스트(선택·복사용)"></textarea></div>
|
||||||
|
<form id="ai-shell-form" class="row" autocomplete="off"><input id="ai-shell-in" placeholder="줄 입력: 글을 쓰고 Enter(코드 붙여넣기·한글 입력용). 저장되지 않습니다" autocomplete="off" style="max-width:none;flex:1"><button class="btn primary small" type="submit">보내기</button><button class="btn small danger" type="button" data-act="ai-shell-kill">세션 종료</button><button class="btn small" type="button" data-act="ai-shell-close">닫기(종료)</button></form>`;
|
||||||
|
}
|
||||||
|
|||||||
+14
-4
@@ -1,4 +1,4 @@
|
|||||||
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드 · 0.3.2 사전 안내·OAuth 자동 진행·텍스트 셸·도구 정책·MCP 설정 · 0.3.9 단말 에뮬레이터 셸(원시 키)·세션 목록·모델 선택·슬래시 명령).
|
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드 · 0.3.2 사전 안내·OAuth 자동 진행·텍스트 셸·도구 정책·MCP 설정 · 0.3.9 단말 에뮬레이터 셸(원시 키)·세션 목록·모델 선택·슬래시 명령 · 0.3.10 셸 복사(선택 Ctrl+C·감지된 주소·전체 복사·텍스트 보기)).
|
||||||
* - 패널(#ai-panel): 탭(통합 고정 + 연결별), 대화 기록, 지시 입력, 제안 승인·거부. 열림/너비/선택 탭은 운영자별 localStorage 에 두고 새로 고침 뒤 복원한다.
|
* - 패널(#ai-panel): 탭(통합 고정 + 연결별), 대화 기록, 지시 입력, 제안 승인·거부. 열림/너비/선택 탭은 운영자별 localStorage 에 두고 새로 고침 뒤 복원한다.
|
||||||
* - 데이터는 /ops/api/ai/* 만 쓴다(CSRF 자동). 응답 대기 중에는 1초, 아니면 4초 간격으로 다시 읽는다(탭이 숨겨지면 멈춤). 화면을 떠나도 턴은 HAL 이 계속한다.
|
* - 데이터는 /ops/api/ai/* 만 쓴다(CSRF 자동). 응답 대기 중에는 1초, 아니면 4초 간격으로 다시 읽는다(탭이 숨겨지면 멈춤). 화면을 떠나도 턴은 HAL 이 계속한다.
|
||||||
* - 제안 승인: 쓰기 → 재인증 창(서버가 reauth_required 로 거부한 그 때만, reauth.mjs 계약), 고위험 → 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행) 뒤 승인 API.
|
* - 제안 승인: 쓰기 → 재인증 창(서버가 reauth_required 로 거부한 그 때만, reauth.mjs 계약), 고위험 → 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행) 뒤 승인 API.
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import * as A from './ai-render.mjs';
|
import * as A from './ai-render.mjs';
|
||||||
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
||||||
import { Term, renderHtml, keyToBytes } from './term.mjs';
|
import { Term, renderHtml, keyToBytes, extractUrls, plainText, copyDecision } from './term.mjs';
|
||||||
const $ = (s, el = document) => el.querySelector(s);
|
const $ = (s, el = document) => el.querySelector(s);
|
||||||
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
@@ -99,16 +99,25 @@ async function startOauth(id) { const r = await api('POST', `/ops/api/ai/connect
|
|||||||
window.addEventListener('message', async (e) => { if (e.origin !== location.origin || !e.data || e.data.type !== 'hal-oauth') return; try { oauthWin && oauthWin.close(); } catch (_) {} if (e.data.ok) { toast('OAuth 로그인 완료 — 토큰을 Vault 에 저장했습니다. 모델 목록을 확인합니다'); if (e.data.connection) { try { await api('POST', `/ops/api/ai/connections/${e.data.connection}/test`); } catch (err) { toast(`확인 실패: ${err.message}`); } } } else toast(`OAuth 로그인 실패: ${e.data.reason || '거부됨'}`); await loadConns(); });
|
window.addEventListener('message', async (e) => { if (e.origin !== location.origin || !e.data || e.data.type !== 'hal-oauth') return; try { oauthWin && oauthWin.close(); } catch (_) {} if (e.data.ok) { toast('OAuth 로그인 완료 — 토큰을 Vault 에 저장했습니다. 모델 목록을 확인합니다'); if (e.data.connection) { try { await api('POST', `/ops/api/ai/connections/${e.data.connection}/test`); } catch (err) { toast(`확인 실패: ${err.message}`); } } } else toast(`OAuth 로그인 실패: ${e.data.reason || '거부됨'}`); await loadConns(); });
|
||||||
// ---------------- [0.3.2 → 0.3.9] 텍스트 셸: 단말 에뮬레이터(term.mjs)로 TUI 를 그리고, 단말 영역에 포커스하면 키를 바이트 그대로 보낸다(화살표·Enter·Esc·Ctrl). 줄 입력란·키 단추도 제공. 닫기는 세션을 종료한다.
|
// ---------------- [0.3.2 → 0.3.9] 텍스트 셸: 단말 에뮬레이터(term.mjs)로 TUI 를 그리고, 단말 영역에 포커스하면 키를 바이트 그대로 보낸다(화살표·Enter·Esc·Ctrl). 줄 입력란·키 단추도 제공. 닫기는 세션을 종료한다.
|
||||||
let shell = null; let shellT = null; let shellM = null;
|
let shell = null; let shellT = null; let shellM = null;
|
||||||
|
/** [0.3.10] 클립보드 복사: Clipboard API → 실패하면 숨은 textarea + execCommand. 긴 주소도 통째로. */
|
||||||
|
async function copyText(t) { if (!t) return toast('복사할 내용이 없습니다'); try { await navigator.clipboard.writeText(t); toast(`복사했습니다(${t.length}자)`); return; } catch (_) {} try { const ta = document.createElement('textarea'); ta.value = t; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); toast(ok ? `복사했습니다(${t.length}자)` : '복사에 실패했습니다 — "텍스트로 보기"에서 직접 선택하세요'); } catch (_) { toast('복사에 실패했습니다 — "텍스트로 보기"에서 직접 선택하세요'); } }
|
||||||
const COLS = 100, ROWS = 30;
|
const COLS = 100, ROWS = 30;
|
||||||
async function openShell(tool, command = 'login', attach = null) {
|
async function openShell(tool, command = 'login', attach = null) {
|
||||||
let r; if (attach) r = { session: attach, guide: [] }; else r = await call('POST', '/ops/api/ai/shell', { tool, command, cols: COLS, rows: ROWS });
|
let r; if (attach) r = { session: attach, guide: [] }; else r = await call('POST', '/ops/api/ai/shell', { tool, command, cols: COLS, rows: ROWS });
|
||||||
shell = { id: r.session.id, next: 0, term: new Term(r.session.cols || COLS, r.session.rows || ROWS), session: r.session };
|
shell = { id: r.session.id, next: 0, term: new Term(r.session.cols || COLS, r.session.rows || ROWS), session: r.session };
|
||||||
shellM = modal(`<h2>텍스트 셸 — ${A.esc(tool)} ${A.esc(command)}</h2>${r.guide && r.guide.length ? `<div class="alert info small"><b>안내</b><ol>${r.guide.map((g) => `<li>${A.esc(g)}</li>`).join('')}</ol></div>` : ''}<div id="ai-shell-view">${A.shellHtml(r.session, '')}</div><div class="ai-term-wrap"><pre class="ai-term" id="ai-term" tabindex="0" title="클릭한 뒤 키를 누르면 그대로 전달됩니다"></pre></div><div class="actions ai-keys">${[['Enter', '\r'], ['Tab', '\t'], ['Esc', '\u001b'], ['↑', '\u001b[A'], ['↓', '\u001b[B'], ['←', '\u001b[D'], ['→', '\u001b[C'], ['y', 'y'], ['n', 'n'], ['Ctrl+C', '\u0003'], ['Ctrl+D', '\u0004']].map(([l, b]) => `<button type="button" class="btn small" data-key="${A.esc(b)}">${A.esc(l)}</button>`).join('')}</div><form id="ai-shell-form" class="row" autocomplete="off"><input id="ai-shell-in" placeholder="줄 입력: 글을 쓰고 Enter(코드 붙여넣기·한글 입력용). 저장되지 않습니다" autocomplete="off" style="max-width:none;flex:1"><button class="btn primary small" type="submit">보내기</button><button class="btn small danger" type="button" data-act="ai-shell-kill">세션 종료</button><button class="btn small" type="button" data-act="ai-shell-close">닫기(종료)</button></form>`);
|
shellM = modal(A.shellModalHtml(r.session, r.guide || []));
|
||||||
const sendBytes = (b) => api('POST', `/ops/api/ai/shell/${shell.id}/input`, { data: b }).catch((err) => toast(err.message));
|
const sendBytes = (b) => api('POST', `/ops/api/ai/shell/${shell.id}/input`, { data: b }).catch((err) => toast(err.message));
|
||||||
const termEl = $('#ai-term', shellM.el);
|
const termEl = $('#ai-term', shellM.el);
|
||||||
termEl.addEventListener('keydown', (e) => { const b = keyToBytes(e); if (b == null) return; e.preventDefault(); if (shell && shell.session.status === 'running') sendBytes(b); });
|
// [0.3.10] 복사 대응: 선택이 있으면 Ctrl+C 는 브라우저 복사(기본 동작)에 맡기고, Ctrl+Shift+C·Ctrl+Insert 는 항상 복사, Ctrl+V 는 붙여넣기(붙여넣기 이벤트가 처리)
|
||||||
|
const selectedText = () => { const sel = window.getSelection ? window.getSelection() : null; return sel && !sel.isCollapsed && termEl.contains(sel.anchorNode) ? sel.toString() : ''; };
|
||||||
|
termEl.addEventListener('keydown', (e) => { const d = copyDecision(e, !!selectedText()); if (d === 'copy') { if (e.shiftKey || e.key === 'Insert') { e.preventDefault(); copyText(selectedText() || plainText(shell.term)); } return; } if (d === 'paste') return; const b = keyToBytes(e); if (b == null) return; e.preventDefault(); if (shell && shell.session.status === 'running') sendBytes(b); });
|
||||||
termEl.addEventListener('paste', (e) => { const t = (e.clipboardData || window.clipboardData).getData('text'); if (t) { e.preventDefault(); sendBytes(t); } });
|
termEl.addEventListener('paste', (e) => { const t = (e.clipboardData || window.clipboardData).getData('text'); if (t) { e.preventDefault(); sendBytes(t); } });
|
||||||
for (const b of shellM.el.querySelectorAll('[data-key]')) b.onclick = () => { sendBytes(b.dataset.key); termEl.focus(); };
|
for (const b of shellM.el.querySelectorAll('[data-key]')) b.onclick = () => { sendBytes(b.dataset.key); termEl.focus(); };
|
||||||
|
shellM.el.addEventListener('click', async (e) => { const el = e.target.closest('[data-act]'); if (!el) return; const act = el.dataset.act;
|
||||||
|
if (act === 'ai-shell-copy-url') { e.preventDefault(); copyText(el.dataset.url); }
|
||||||
|
else if (act === 'ai-shell-copy-all') { e.preventDefault(); copyText(plainText(shell.term)); }
|
||||||
|
else if (act === 'ai-shell-copy-sel') { e.preventDefault(); const t = selectedText(); if (!t) return toast('단말에서 글을 먼저 드래그로 선택하세요'); copyText(t); }
|
||||||
|
else if (act === 'ai-shell-text') { e.preventDefault(); const tv = $('#ai-shell-textview', shellM.el); tv.hidden = !tv.hidden; if (!tv.hidden) { tv.value = plainText(shell.term); tv.focus(); tv.select(); } } });
|
||||||
$('#ai-shell-form', shellM.el).onsubmit = async (e) => { e.preventDefault(); const inp = $('#ai-shell-in', shellM.el); const v = inp.value; inp.value = ''; await sendBytes(v + '\r'); };
|
$('#ai-shell-form', shellM.el).onsubmit = async (e) => { e.preventDefault(); const inp = $('#ai-shell-in', shellM.el); const v = inp.value; inp.value = ''; await sendBytes(v + '\r'); };
|
||||||
const closeShell = async () => { clearTimeout(shellT); if (shell && shell.session.status === 'running') { try { await api('DELETE', `/ops/api/ai/shell/${shell.id}`); } catch (_) {} } shellM.close(); shell = null; loadShells(); loadClis(); loadConns(); };
|
const closeShell = async () => { clearTimeout(shellT); if (shell && shell.session.status === 'running') { try { await api('DELETE', `/ops/api/ai/shell/${shell.id}`); } catch (_) {} } shellM.close(); shell = null; loadShells(); loadClis(); loadConns(); };
|
||||||
shellM.el.querySelector('[data-act=ai-shell-close]').onclick = closeShell; shellM.el.querySelector('[data-act=ai-shell-kill]').onclick = () => api('DELETE', `/ops/api/ai/shell/${shell.id}`).catch((err) => toast(err.message));
|
shellM.el.querySelector('[data-act=ai-shell-close]').onclick = closeShell; shellM.el.querySelector('[data-act=ai-shell-kill]').onclick = () => api('DELETE', `/ops/api/ai/shell/${shell.id}`).catch((err) => toast(err.message));
|
||||||
@@ -118,6 +127,7 @@ async function openShell(tool, command = 'login', attach = null) {
|
|||||||
async function pollShell() {
|
async function pollShell() {
|
||||||
if (!shell) return;
|
if (!shell) return;
|
||||||
try { const r = await api('GET', `/ops/api/ai/shell/${shell.id}?after=${shell.next}`); if (r.output) shell.term.feed(r.output); shell.next = r.next; shell.session = r.session; const view = $('#ai-shell-view'); if (view) view.innerHTML = A.shellHtml(r.session, ''); const el = $('#ai-term'); if (el) { el.innerHTML = renderHtml(shell.term); el.scrollTop = el.scrollHeight; }
|
try { const r = await api('GET', `/ops/api/ai/shell/${shell.id}?after=${shell.next}`); if (r.output) shell.term.feed(r.output); shell.next = r.next; shell.session = r.session; const view = $('#ai-shell-view'); if (view) view.innerHTML = A.shellHtml(r.session, ''); const el = $('#ai-term'); if (el) { el.innerHTML = renderHtml(shell.term); el.scrollTop = el.scrollHeight; }
|
||||||
|
const urls = extractUrls(shell.term); const ul = $('#ai-shell-urls'); if (ul && ul.dataset.sig !== urls.join('|')) { ul.dataset.sig = urls.join('|'); ul.innerHTML = A.shellUrlsHtml(urls); } const tv = $('#ai-shell-textview'); if (tv && !tv.hidden && document.activeElement !== tv) tv.value = plainText(shell.term); // [0.3.10] 감지된 주소·텍스트 보기 갱신
|
||||||
if (r.session.status === 'running') shellT = setTimeout(pollShell, 300); else { toast(`셸 종료(코드 ${r.session.exitCode}) — 로그인 상태를 다시 탐색합니다`); loadShells(); loadClis(); loadConns(); } }
|
if (r.session.status === 'running') shellT = setTimeout(pollShell, 300); else { toast(`셸 종료(코드 ${r.session.exitCode}) — 로그인 상태를 다시 탐색합니다`); loadShells(); loadClis(); loadConns(); } }
|
||||||
catch (e) { toast(e.message); }
|
catch (e) { toast(e.message); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,5 @@ body.login{display:grid;place-items:center;min-height:100vh}.login-box{backgroun
|
|||||||
.ai-tool-group{margin:6px 0}.ai-tool-list{display:flex;flex-wrap:wrap;gap:4px 12px;margin:4px 0}.ai-tool-list label{font-weight:400;margin:0}
|
.ai-tool-group{margin:6px 0}.ai-tool-list{display:flex;flex-wrap:wrap;gap:4px 12px;margin:4px 0}.ai-tool-list label{font-weight:400;margin:0}
|
||||||
/* 0.3.9 단말 */
|
/* 0.3.9 단말 */
|
||||||
.ai-term{background:#101826;color:#e6edf3;font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.25;padding:8px;border-radius:8px;min-height:240px;max-height:55vh;overflow:auto;white-space:pre;outline:none;margin:0}.ai-term:focus{box-shadow:0 0 0 2px var(--pri)}.term-cur{background:#e6edf3;color:#101826}.ai-keys{gap:4px}.ai-model-pick{width:auto;max-width:220px;padding:3px 6px;font-size:12px}
|
.ai-term{background:#101826;color:#e6edf3;font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.25;padding:8px;border-radius:8px;min-height:240px;max-height:55vh;overflow:auto;white-space:pre;outline:none;margin:0}.ai-term:focus{box-shadow:0 0 0 2px var(--pri)}.term-cur{background:#e6edf3;color:#101826}.ai-keys{gap:4px}.ai-model-pick{width:auto;max-width:220px;padding:3px 6px;font-size:12px}
|
||||||
|
/* 0.3.10 셸 복사·주소 */
|
||||||
|
.ai-term{user-select:text;cursor:text}.ai-urls{list-style:none;padding:0;margin:4px 0}.ai-urls li{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:3px 0}.ai-url{word-break:break-all;background:#f7f9fc;border:1px solid var(--line);border-radius:6px;padding:2px 6px;font-size:12px}.ai-shell-tools{margin:6px 0}.ai-shell-textview{width:100%;max-width:none;font-family:ui-monospace,Menlo,monospace;font-size:12px;margin-top:6px}
|
||||||
|
|||||||
@@ -76,3 +76,29 @@ export function keyToBytes(e) {
|
|||||||
const map = { Enter: '\r', Backspace: '\x7f', Tab: '\t', Escape: '\x1b', ArrowUp: '\x1b[A', ArrowDown: '\x1b[B', ArrowRight: '\x1b[C', ArrowLeft: '\x1b[D', Home: '\x1b[H', End: '\x1b[F', Delete: '\x1b[3~', PageUp: '\x1b[5~', PageDown: '\x1b[6~', Insert: '\x1b[2~' };
|
const map = { Enter: '\r', Backspace: '\x7f', Tab: '\t', Escape: '\x1b', ArrowUp: '\x1b[A', ArrowDown: '\x1b[B', ArrowRight: '\x1b[C', ArrowLeft: '\x1b[D', Home: '\x1b[H', End: '\x1b[F', Delete: '\x1b[3~', PageUp: '\x1b[5~', PageDown: '\x1b[6~', Insert: '\x1b[2~' };
|
||||||
if (map[k]) return map[k]; if (k.length === 1 && !ctrl && !e.altKey) return k; return null;
|
if (map[k]) return map[k]; if (k.length === 1 && !ctrl && !e.altKey) return k; return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [0.3.10] 스크롤백 + 화면의 모든 줄. 줄이 꽉 찬 경우(열 수만큼) 다음 줄과 이어진 것으로 보고 붙인다(긴 주소가 줄 끝에서 끊긴 경우). */
|
||||||
|
export function joinedLines(term) {
|
||||||
|
const raw = [...term.scrollback(), ...term.grid.map((r) => r.join(''))].map((l) => l.replace(/\s+$/, ''));
|
||||||
|
const out = []; let cur = null;
|
||||||
|
for (const l of raw) { if (cur === null) cur = l; else cur += l; if (cur.length > 0 && l.length === term.cols) continue; out.push(cur); cur = null; }
|
||||||
|
if (cur !== null) out.push(cur); return out;
|
||||||
|
}
|
||||||
|
/** [0.3.10] 복사·붙여넣기용 전체 글(이어 붙인 줄 기준) */
|
||||||
|
export const plainText = (term) => joinedLines(term).join('\n');
|
||||||
|
const URL_RE = /https?:\/\/[^\s<>"'`│┃║|]+/g;
|
||||||
|
/** [0.3.10] 출력에 나온 주소(중복 제거, 순서 유지). 꼬리 구두점은 뗀다. */
|
||||||
|
export function extractUrls(term) {
|
||||||
|
const seen = new Set(); const out = [];
|
||||||
|
for (const line of joinedLines(term)) for (const m of line.matchAll(URL_RE)) { const u = m[0].replace(/[.,;:!?)\]}'"]+$/, ''); if (u.length > 10 && !seen.has(u)) { seen.add(u); out.push(u); } }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/** [0.3.10] 단말 영역의 키 처리: 'copy'(브라우저 복사에 맡김) · 'paste'(클립보드 붙여넣기) · 'send'(셸로 전송). 선택된 글이 있으면 Ctrl+C 는 복사. */
|
||||||
|
export function copyDecision(e, hasSelection) {
|
||||||
|
const k = String(e.key || '').toLowerCase(); const ctrl = !!(e.ctrlKey || e.metaKey);
|
||||||
|
if (ctrl && k === 'insert') return 'copy';
|
||||||
|
if (ctrl && k === 'c') return e.shiftKey || hasSelection ? 'copy' : 'send';
|
||||||
|
if (ctrl && k === 'v') return 'paste';
|
||||||
|
if (e.shiftKey && k === 'insert') return 'paste';
|
||||||
|
return 'send';
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ariweb-suite",
|
"name": "ariweb-suite",
|
||||||
"version": "0.3.9",
|
"version": "0.3.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* test/hal-ai-0310.test.mjs — Ariweb Suite 0.3.10(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.10.md`, Oz 2026-10-29): 텍스트 셸 복사·주소 추출.
|
||||||
|
*
|
||||||
|
* 계약
|
||||||
|
* 1) 단말 에뮬레이터가 출력에서 URL 을 뽑아 준다(`extractUrls`). 줄 끝에서 끊겨 다음 줄로 이어진 긴 주소(줄이 꽉 찬 경우)는 이어 붙인다. 스크롤백·상자 문자(│)·마침표도 처리.
|
||||||
|
* 2) 단말 영역에서 글을 선택한 채 Ctrl+C 를 누르면 셸로 보내지 않고 복사한다(`copyDecision`). Ctrl+Shift+C 는 항상 복사, Ctrl+Insert 도 복사.
|
||||||
|
* 3) 셸 화면(순수 렌더러)에 "감지된 주소" 목록(복사·열기), 전체 출력 복사, 텍스트로 보기(선택 가능한 텍스트 상자), 복사 안내가 있다.
|
||||||
|
*/
|
||||||
|
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
|
||||||
|
import { invoke } from '../lib/http.mjs';
|
||||||
|
import { createHalDeps } from '../hal/deps.mjs';
|
||||||
|
import { buildApp } from '../hal/app.mjs';
|
||||||
|
import * as ops from '../hal/ops.mjs';
|
||||||
|
import { mkdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
await test('URL 추출: 줄 끝에서 끊긴 긴 주소 이어 붙이기, 스크롤백 포함, 상자 문자·마침표 제외, 중복 제거', async () => {
|
||||||
|
const T = await import('../hal/ui/public/term.mjs');
|
||||||
|
const t = new T.Term(40, 4);
|
||||||
|
t.feed('Open this URL in your browser:\r\n');
|
||||||
|
t.feed('https://auth.example.test/oauth/authorize?c=' + 'abc123&state=xyz paste code\r\n'); // 44자 → 40에서 끊겨 다음 줄로
|
||||||
|
eq(t.lines()[1].length, 40, '줄이 꽉 찼다'); eq(T.extractUrls(t), ['https://auth.example.test/oauth/authorize?c=abc123&state=xyz']);
|
||||||
|
// 꽉 차지 않은 줄 끝의 주소는 다음 줄과 잇지 않는다
|
||||||
|
const t2 = new T.Term(60, 4); t2.feed('see https://a.example.test/x.\r\nnext line https://b.example.test/y\r\n'); eq(T.extractUrls(t2), ['https://a.example.test/x', 'https://b.example.test/y']);
|
||||||
|
// 상자 문자 안의 주소, 스크롤백으로 밀려난 주소, 중복
|
||||||
|
const t3 = new T.Term(50, 2); t3.feed('│ https://c.example.test/login?code=1 │\r\n'); t3.feed('again https://c.example.test/login?code=1\r\n'); t3.feed('x\r\ny\r\n'); ok(t3.scrollback().length >= 1); eq(T.extractUrls(t3), ['https://c.example.test/login?code=1']);
|
||||||
|
// 대체 화면(TUI)에서도
|
||||||
|
const t4 = new T.Term(30, 3); t4.feed('\x1b[?1049h'); t4.feed('Visit https://d.example.test/dev'); t4.feed('ice?user_code=ABCD-EFGH now'); eq(T.extractUrls(t4), ['https://d.example.test/device?user_code=ABCD-EFGH']);
|
||||||
|
// 단말 글 전체(스크롤백 + 화면, 이어 붙인 줄 포함)
|
||||||
|
ok(T.plainText(t).includes('abc123&state=xyz'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('복사 판정: 선택이 있으면 Ctrl+C 는 복사, 없으면 셸로 전송; Ctrl+Shift+C·Ctrl+Insert 는 항상 복사; Ctrl+V 는 붙여넣기', async () => {
|
||||||
|
const T = await import('../hal/ui/public/term.mjs');
|
||||||
|
eq(T.copyDecision({ key: 'c', ctrlKey: true }, true), 'copy'); eq(T.copyDecision({ key: 'c', ctrlKey: true }, false), 'send');
|
||||||
|
eq(T.copyDecision({ key: 'C', ctrlKey: true, shiftKey: true }, false), 'copy'); eq(T.copyDecision({ key: 'Insert', ctrlKey: true }, false), 'copy');
|
||||||
|
eq(T.copyDecision({ key: 'v', ctrlKey: true, shiftKey: true }, false), 'paste'); eq(T.copyDecision({ key: 'v', ctrlKey: true }, false), 'paste');
|
||||||
|
eq(T.copyDecision({ key: 'd', ctrlKey: true }, true), 'send'); eq(T.copyDecision({ key: 'Enter' }, true), 'send');
|
||||||
|
eq(T.keyToBytes({ key: 'c', ctrlKey: true }), '\u0003');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('셸 화면(순수 렌더러): 감지된 주소 목록(복사·열기, 이스케이프), 전체 복사·텍스트로 보기·복사 안내가 있는 모달', async () => {
|
||||||
|
const AR = await import('../hal/ui/public/ai-render.mjs');
|
||||||
|
const urls = AR.shellUrlsHtml(['https://auth.example.test/a?x=1&y=<b>']); ok(urls.includes('&y=<b>') && !urls.includes('<b>'), '이스케이프'); ok(/data-act="ai-shell-copy-url"/.test(urls) && /복사/.test(urls) && /target="_blank"/.test(urls) && /rel="noopener/.test(urls) && /열기/.test(urls));
|
||||||
|
ok(AR.shellUrlsHtml([]).includes('감지된 주소 없음'));
|
||||||
|
const m = AR.shellModalHtml({ id: 's1', tool: 'claude', command: 'login', status: 'running', cols: 100, rows: 30 }, ['안내 1']);
|
||||||
|
for (const s of ['id="ai-term"', 'id="ai-shell-urls"', 'data-act="ai-shell-copy-all"', 'data-act="ai-shell-text"', 'id="ai-shell-textview"', 'Ctrl+Shift+C', '안내 1', 'data-key=']) ok(m.includes(s), '모달 요소 ' + s);
|
||||||
|
ok(!m.includes('<안내'), '안내 이스케이프');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('버전·문서(0.3.10)', async () => {
|
||||||
|
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
|
||||||
|
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, values: {}, set(p) { Object.assign(this, p); } };
|
||||||
|
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('no net'); } }); const app = buildApp(d);
|
||||||
|
const pw = 'pw-' + randomBytes(12).toString('hex'); ops.createAccount(d, { login: 'ozops', password: pw, displayName: '오즈' }); const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } });
|
||||||
|
const r = await invoke(app, 'GET', '/ops/ai', { cookies: { aw_ops: l.cookies.aw_ops } }); ok(r.text.includes('복사'), '셸 안내에 복사 방법');
|
||||||
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(d.version, '0.3.10');
|
||||||
|
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.10.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.10'));
|
||||||
|
});
|
||||||
|
done();
|
||||||
@@ -192,7 +192,7 @@ await test('화면·버전·문서(0.3.2): AI 연동 화면에 프리셋·사전
|
|||||||
for (const s of ['OpenRouter', 'Gemini', 'ChatGPT', '기타', '사전 준비', '텍스트 셸', 'MCP', '도구 정책']) ok(r.text.includes(s), '화면 문구 ' + s);
|
for (const s of ['OpenRouter', 'Gemini', 'ChatGPT', '기타', '사전 준비', '텍스트 셸', 'MCP', '도구 정책']) ok(r.text.includes(s), '화면 문구 ' + s);
|
||||||
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.shellHtml === 'function' && typeof AR.guideHtml === 'function' && typeof AR.toolPolicyHtml === 'function');
|
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.shellHtml === 'function' && typeof AR.guideHtml === 'function' && typeof AR.toolPolicyHtml === 'function');
|
||||||
ok(AR.shellHtml({ id: 's1', status: 'running', tool: 'claude', command: 'login' }, 'Open <https://x> paste').includes('<https://x>'));
|
ok(AR.shellHtml({ id: 's1', status: 'running', tool: 'claude', command: 'login' }, 'Open <https://x> paste').includes('<https://x>'));
|
||||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.9'); eq(h.d.version, '0.3.9');
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(h.d.version, '0.3.10');
|
||||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.2.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.2'));
|
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.2.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.2'));
|
||||||
});
|
});
|
||||||
done();
|
done();
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ await test('화면·버전·문서(0.3.9): 셸 화면에 원시 키·단말 에
|
|||||||
eq((await invoke(h.app, 'GET', '/ops/ui/term.mjs')).status, 200);
|
eq((await invoke(h.app, 'GET', '/ops/ui/term.mjs')).status, 200);
|
||||||
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.modelSelectHtml === 'function' && typeof AR.shellSessionsHtml === 'function' && typeof AR.commandsHelpHtml === 'function');
|
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.modelSelectHtml === 'function' && typeof AR.shellSessionsHtml === 'function' && typeof AR.commandsHelpHtml === 'function');
|
||||||
ok(AR.modelSelectHtml({ id: 'c1', model: 'a', models: ['a', 'b<'] }).includes('b<') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
|
ok(AR.modelSelectHtml({ id: 'c1', model: 'a', models: ['a', 'b<'] }).includes('b<') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
|
||||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.9'); eq(h.d.version, '0.3.9');
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(h.d.version, '0.3.10');
|
||||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.9.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.9'));
|
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.9.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.9'));
|
||||||
});
|
});
|
||||||
done();
|
done();
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ await test('화면·버전·문서: 공통 틀에 AI 패널(통합 탭 고정)·
|
|||||||
r = await invoke(h.app, 'GET', '/ops/ui/ai.js'); eq(r.status, 200); r = await invoke(h.app, 'GET', '/ops/ui/ai-render.mjs'); eq(r.status, 200);
|
r = await invoke(h.app, 'GET', '/ops/ui/ai.js'); eq(r.status, 200); r = await invoke(h.app, 'GET', '/ops/ui/ai-render.mjs'); eq(r.status, 200);
|
||||||
const AR = await import('../hal/ui/public/ai-render.mjs'); const tabs = AR.tabsHtml([{ id: 'room_group', kind: 'group', title: '통합', fixed: true }, { id: 'r2', kind: 'direct', title: '<네오>', connection: { status: 'active' } }], 'r2'); ok(tabs.indexOf('통합') < tabs.indexOf('<네오>') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
|
const AR = await import('../hal/ui/public/ai-render.mjs'); const tabs = AR.tabsHtml([{ id: 'room_group', kind: 'group', title: '통합', fixed: true }, { id: 'r2', kind: 'direct', title: '<네오>', connection: { status: 'active' } }], 'r2'); ok(tabs.indexOf('통합') < tabs.indexOf('<네오>') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
|
||||||
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('<b>'));
|
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('<b>'));
|
||||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.9'); eq(h.d.version, '0.3.9');
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10'); eq(h.d.version, '0.3.10');
|
||||||
r = await op.as('GET', '/ops/api/ai/skill'); eq(r.status, 200); ok(r.json.skill.includes('pending') && r.json.skill.includes('needs_user') && r.json.skill.includes('reauth_required'), '상태·로그 의미 설명');
|
r = await op.as('GET', '/ops/api/ai/skill'); eq(r.status, 200); ok(r.json.skill.includes('pending') && r.json.skill.includes('needs_user') && r.json.skill.includes('reauth_required'), '상태·로그 의미 설명');
|
||||||
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.0.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.0'); ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.1.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.1'); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.1'));
|
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.0.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.0'); ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.1.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.1'); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.1'));
|
||||||
ok(readFileSync(join(ROOT, 'docs', 'HAL-AI.md'), 'utf8').includes('hal_servers_list'), 'AI 도구 문서');
|
ok(readFileSync(join(ROOT, 'docs', 'HAL-AI.md'), 'utf8').includes('hal_servers_list'), 'AI 도구 문서');
|
||||||
@@ -315,7 +315,7 @@ await test('MCP(stdio) 서버: initialize·tools/list·읽기 도구 call 은
|
|||||||
const send = (o) => child.stdin.write(JSON.stringify(o) + '\n'); const waitFor = async (id) => { for (let i = 0; i < 200; i++) { const m = out.find((x) => x.id === id); if (m) return m; await sleep(25); } throw new Error('MCP 응답 없음 ' + id); };
|
const send = (o) => child.stdin.write(JSON.stringify(o) + '\n'); const waitFor = async (id) => { for (let i = 0; i < 200; i++) { const m = out.find((x) => x.id === id); if (m) return m; await sleep(25); } throw new Error('MCP 응답 없음 ' + id); };
|
||||||
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test' } } }); const init = await waitFor(1); ok(init.result.serverInfo.name.includes('HAL') && init.result.capabilities.tools);
|
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test' } } }); const init = await waitFor(1); ok(init.result.serverInfo.name.includes('HAL') && init.result.capabilities.tools);
|
||||||
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const tl = await waitFor(2); ok(tl.result.tools.some((t) => t.name === 'hal_servers_list' && t.inputSchema)); ok(!tl.result.tools.some((t) => /shell|exec/.test(t.name)));
|
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const tl = await waitFor(2); ok(tl.result.tools.some((t) => t.name === 'hal_servers_list' && t.inputSchema)); ok(!tl.result.tools.some((t) => /shell|exec/.test(t.name)));
|
||||||
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.3.9');
|
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.3.10');
|
||||||
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'k', title: 't', severity: 'warning' } } }); const wc = await waitFor(4); ok(!wc.result.isError && /승인/.test(wc.result.content[0].text) && JSON.parse(wc.result.content[0].text).approvalRequested === true, JSON.stringify(wc)); // [0.3.2] 오류가 아니라 승인 요청(제안)으로 올라간다
|
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'k', title: 't', severity: 'warning' } } }); const wc = await waitFor(4); ok(!wc.result.isError && /승인/.test(wc.result.content[0].text) && JSON.parse(wc.result.content[0].text).approvalRequested === true, JSON.stringify(wc)); // [0.3.2] 오류가 아니라 승인 요청(제안)으로 올라간다
|
||||||
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'nope', arguments: {} } }); const nc = await waitFor(5); ok(nc.error || nc.result.isError);
|
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'nope', arguments: {} } }); const nc = await waitFor(5); ok(nc.error || nc.result.isError);
|
||||||
child.kill(); await sleep(50);
|
child.kill(); await sleep(50);
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ const require = createRequire(import.meta.url);
|
|||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const WM = join(ROOT, 'modules', 'webmaker');
|
const WM = join(ROOT, 'modules', 'webmaker');
|
||||||
|
|
||||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.9(HAL AI 셸·모델 선택·슬래시 명령, 도입 모듈 변경 없음) 이다', () => {
|
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.10(HAL AI 셸 복사, 도입 모듈 변경 없음) 이다', () => {
|
||||||
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
|
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
|
||||||
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.8.0');
|
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.8.0');
|
||||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.9');
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.10');
|
||||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
||||||
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
|
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
|
||||||
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');
|
||||||
|
|||||||
Reference in New Issue
Block a user