From 083505c9526f77d9db91d6e9f7f0e457aff8f7a1 Mon Sep 17 00:00:00 2001 From: Tom You Date: Thu, 11 Jun 2026 15:16:51 +0900 Subject: [PATCH] init --- .env.ai-ready | 18 + .github/workflows/auto-bump-patch.yml | 107 + .github/workflows/ci.yml | 41 + .gitignore | 51 + .vscode/settings.json | 10 + AGENTS.md | 92 + CLAUDE.md | 1 + README.ai-ready.md | 119 + README.md | 133 + SPEC.md | 108 + bin/damn-my-slow-skt | 4 + config.yaml.example | 38 + eslint.config.mjs | 33 + package-lock.json | 3641 +++++++++++++++++++++++++ package.json | 66 + src/cli.ts | 1075 ++++++++ src/config.ts | 271 ++ src/db.ts | 257 ++ src/docker.ts | 146 + src/index.ts | 13 + src/migration.ts | 194 ++ src/notify.ts | 96 + src/report.ts | 72 + src/scheduler.ts | 556 ++++ src/skt.ts | 982 +++++++ src/updater.ts | 102 + tests/config.test.ts | 187 ++ tests/db.test.ts | 89 + tsconfig.json | 19 + vitest.config.ts | 9 + 30 files changed, 8530 insertions(+) create mode 100644 .env.ai-ready create mode 100644 .github/workflows/auto-bump-patch.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.ai-ready.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100755 bin/damn-my-slow-skt create mode 100644 config.yaml.example create mode 100644 eslint.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/db.ts create mode 100644 src/docker.ts create mode 100644 src/index.ts create mode 100644 src/migration.ts create mode 100644 src/notify.ts create mode 100644 src/report.ts create mode 100644 src/scheduler.ts create mode 100644 src/skt.ts create mode 100644 src/updater.ts create mode 100644 tests/config.test.ts create mode 100644 tests/db.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.env.ai-ready b/.env.ai-ready new file mode 100644 index 0000000..0e18d1e --- /dev/null +++ b/.env.ai-ready @@ -0,0 +1,18 @@ +# damn-my-slow-skt — AI Agent용 환경 설정 +# 이 프로젝트는 .env를 사용하지 않습니다. +# 설정은 ~/.damn-my-slow-isp/config-skt.yaml (YAML)로 관리됩니다. +# 아래는 AI Agent가 개발/테스트할 때 참고할 정보입니다. +# +# 실제 동작에 필요한 credential 및 약관 동의: +# SKT_ID=your-skt-account@example.com +# SKT_PASSWORD=your-password +# terms.accepted=true, 현재 version, 유효한 accepted_at 필요 +# +# 선택적 알림 설정: +# DISCORD_WEBHOOK=https://discord.com/api/webhooks/... +# TELEGRAM_BOT_TOKEN=123456:ABC... +# TELEGRAM_CHAT_ID=123456789 +# +# 위 값들은 config-skt.yaml에 설정합니다: +# cp config.yaml.example ~/.damn-my-slow-isp/config-skt.yaml +# # 그 후 공식 이용약관 URL 확인 및 credential 입력 diff --git a/.github/workflows/auto-bump-patch.yml b/.github/workflows/auto-bump-patch.yml new file mode 100644 index 0000000..79a1aaf --- /dev/null +++ b/.github/workflows/auto-bump-patch.yml @@ -0,0 +1,107 @@ +name: Auto Bump and Publish + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + bump: + description: Version bump type + required: true + default: patch + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + bump-and-publish: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + + steps: + - name: Determine bump type + id: bump_type + run: echo "value=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bump || 'patch' }}" >> "$GITHUB_OUTPUT" + + - name: Wait 5 minutes for more commits + if: github.event_name != 'workflow_dispatch' + run: sleep 300 + + - name: Checkout latest branch + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: npm ci + + - name: Sync version with npm registry + id: sync + run: | + # npm 레지스트리에 이미 publish된 최신 버전을 확인하여 + # 로컬 package.json과 불일치하면 동기화한다. + # (이전 CI에서 publish 성공 후 commit이 실패한 경우 발생) + LOCAL=$(node -p "require('./package.json').version") + REMOTE=$(npm view damn-my-slow-skt version 2>/dev/null || echo "0.0.0") + echo "local=$LOCAL remote=$REMOTE" + + if npx semver "$REMOTE" -r ">$LOCAL" > /dev/null 2>&1; then + echo "⚠️ npm registry ($REMOTE) > local ($LOCAL). Syncing..." + npm version "$REMOTE" --no-git-tag-version --allow-same-version + fi + + - name: Bump version + id: bump + run: | + npm version ${{ steps.bump_type.outputs.value }} --no-git-tag-version + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build + run: npm run build + + - name: Commit and push version bump + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add package.json package-lock.json 2>/dev/null || true + + if git diff --cached --quiet; then + echo "No changes to commit." + else + git commit -m "chore: auto bump ${{ steps.bump_type.outputs.value }} to v${{ steps.bump.outputs.version }}" + git push origin HEAD:${{ github.ref_name }} + fi + + - name: Publish to npm (Trusted Publishers OIDC) + run: npm publish --provenance --access public + + - name: Tag and release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git tag "v${{ steps.bump.outputs.version }}" + git push origin "v${{ steps.bump.outputs.version }}" + gh release create "v${{ steps.bump.outputs.version }}" --generate-notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fde5cb8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-and-test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf75a4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Config (contains credentials) +config.yaml +config.yml +config-skt.yaml + +# Environment +.env +.env.* +!.env.ai-ready +!.env.example + +# Node.js +node_modules/ + +# Build output +dist/ + +# Database +*.db +*.sqlite +*.sqlite3 +*.json.db + +# Puppeteer cache +.cache/ + +# Logs +*.log + +# macOS +.DS_Store + +# IDE +.idea/ +*.swp + +# Screenshots +skt-error.png +screenshot.png +speed-test-result.png + +# Update cache +.update-cache.json + +# Sisyphus (AI agent planner) +.sisyphus/ +!.sisyphus/.gitignore +!.sisyphus/plans/ +!.sisyphus/evidence/ + +.claude/autoresearch-results.tsv diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..958d13d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": ["typescript"], + "testing.automaticallyOpenPeekView": "never" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..65196f5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# AGENTS.md — damn-my-slow-skt + +> AI Agent rules and codebase documentation. Update this file when completing tasks. + +## Project Overview +- **Name**: damn-my-slow-skt v0.5.x +- **Purpose**: SKT/SK Broadband internet SLA speed measurement automation + fee reduction CLI tool +- **What it does**: Automates the flow of entering SK Broadband Myspeed, running SLA speed tests, storing results, and preparing fee-reduction handling when speed is below the SLA threshold. +- **Stack**: TypeScript (ES2020, CommonJS), Node.js 20+, Playwright, SQLite (node:sqlite), Commander CLI +- **Package manager**: npm (use `npm install`, never yarn/pnpm) +- **License**: MIT +- **npm package**: `damn-my-slow-skt` +- **Language of product**: Korean (UI strings, README, commit messages) +- **Language of code**: English (variable names, comments explain why) + +## Development Setup + +```bash +npm install +npx playwright install chromium +npm run build +npm run typecheck +npm run lint +npm test +``` + +- No external DB, Redis, or Docker required +- Config file: `~/.damn-my-slow-isp/config-skt.yaml` (YAML, not .env) +- Config requires explicit SKT/SK Broadband official terms acceptance under `terms` +- `run` command requires real SKT/B world credentials or SK Broadband authentication; all other dev tasks work without credentials + +## Codebase Structure + +``` +src/ +├── index.ts +├── cli.ts +├── config.ts +├── db.ts +├── skt.ts # SKT/SK Broadband Myspeed Playwright automation +├── migration.ts +├── notify.ts +├── report.ts +├── scheduler.ts +└── updater.ts +tests/ +├── config.test.ts +├── db.test.ts +``` + +### Key Files Explained +- **skt.ts**: Core browser automation. Drives SK Broadband Myspeed SLA flow. Do not change live-site selectors without browser verification against myspeed.skbroadband.com. +- **cli.ts**: Commander commands. `run` validates required config fields before execution. +- **config.ts**: YAML config load/save, interfaces, defaults, required field validation. +- **scheduler.ts**: Generates launchd/systemd/cron triggers from `max_attempts` and `retry_interval_minutes`. +- **db.ts**: Dual storage backend: `node:sqlite` first, JSON fallback on Node 20. + +## Coding Conventions + +### TypeScript +- **Module**: CommonJS compatible dependencies +- **Target**: ES2020, `strict: true` +- **Error handling**: Always `catch (e: unknown)`, then narrow to `Error` +- **No new `any`**: Use `unknown` and type guards +- **String formatting**: Template literals +- **Async**: async/await + +### Console Output +- Use `chalk` for colors +- Use emoji as status indicators (✅ ❌ 📊 ⏱ 📡 🐌) +- Check `process.stdout.isTTY` for interactive vs cron mode + +### Config System +- YAML-based, not .env +- Deep-merged with defaults on load +- Config version tracked in `_config_version` (current: 4) +- `terms` stores current SKT/SK Broadband terms acceptance metadata and is required for `run` +- Migrations are interactive + +## Restrictions +- **Never commit credentials** — local config files are gitignored +- **Never break CJS compatibility** +- **Never modify SK Broadband automation selectors** without testing against the live site +- **Never use `process.exit()` in library code** — only CLI entry points +- **Node 20 minimum** — do not use Node 22+ APIs without fallback + +## Mandatory Practices +- Update AGENTS.md when completing tasks that change structure, conventions, or dependencies +- Run: `npm run typecheck && npm run lint && npm run build && npm test` +- Use Korean conventional commits +- Keep screenshot-on-error pattern in `skt.ts` +- Test with `--dry-run` when possible to avoid filing real complaints diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.ai-ready.md b/README.ai-ready.md new file mode 100644 index 0000000..1c8dd9a --- /dev/null +++ b/README.ai-ready.md @@ -0,0 +1,119 @@ +# AI Agentic Coding Setup — damn-my-slow-skt + +> **이 문서는 AI 에이전트(Codex, Claude Code, OpenCode 등)가 이 프로젝트를 개발/테스트할 때 필요한 환경 설정을 안내합니다.** +> 사람 개발자가 AI 코딩 환경을 구성할 때도 참고하세요. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────┐ +│ damn-my-slow-skt │ +│ │ +│ CLI (Commander) │ +│ ├── init → 설정 wizard + 스케줄 등록 │ +│ ├── run → Playwright → myspeed.skbroadband.com 측정 │ +│ ├── history → SQLite/JSON DB 조회 │ +│ ├── report → 월간 통계 │ +│ └── schedule → launchd/systemd/cron 등록 │ +│ │ +│ Storage: SQLite (Node 22+) / JSON fallback (20+) │ +│ Config: ~/.damn-my-slow-isp/config-skt.yaml │ +│ Terms: SKT/SK Broadband agreement required │ +│ No external DB/Redis/Docker required! │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## Quick Setup (Local Development) + +```bash +# 1. Install dependencies +npm install + +# 2. Install Playwright browsers (headless Chromium) +npx playwright install chromium + +# 3. Build +npm run build + +# 4. Create test config (optional — only needed for actual SKT/SK브로드밴드 measurement) +cp config.yaml.example ~/.damn-my-slow-isp/config-skt.yaml +# Edit with your SKT/B world credentials and keep the terms block accepted only after reviewing the official URLs + +# 5. Run type check + lint + tests +npm run typecheck +npm run lint +npm test +``` + +--- + +## Codex Cloud Setup (Ubuntu 24.04) + +> **Codex Cloud는 Docker를 사용할 수 없습니다.** +> 이 프로젝트는 외부 서비스(MySQL, Redis 등)가 불필요하므로 바로 사용 가능합니다. + +### Setup Script (네트워크 접근 가능 시) + +```bash +#!/bin/bash +# Codex Cloud: 초기 설정 (network enabled) +npm install +npx playwright install-deps chromium # 시스템 의존성 (Ubuntu) +npx playwright install chromium # Chromium 브라우저 바이너리 +npm run build +``` + +### Maintain Script (브랜치 전환 후) + +```bash +#!/bin/bash +# Codex Cloud: 브랜치 체크아웃 후 유지보수 +npm install +npm run build +``` + +--- + +## Required Secrets + +| Secret | Required | Purpose | +|--------|----------|---------| +| SKT/B world ID/Password | **Yes** (for `run` only) | SKT/B world 계정 — `config-skt.yaml`에 설정 | +| SKT/SK Broadband Terms Acceptance | **Yes** (for `run` only) | `terms` block in `config-skt.yaml`; generated by `init` or v4 migration | +| Discord Webhook | No | 결과 알림 | +| Telegram Token | No | 결과 알림 | + +> **개발/테스트 시에는 credential 없이도** `build`, `typecheck`, `lint`, `test` 모두 실행 가능합니다. +> `run` 명령만 실제 SKT/B world 계정이 필요합니다. 단, 실행 전 현재 SKT/SK브로드밴드 공식 이용약관 동의(`terms.accepted`, `version`, `accepted_at`)도 필요합니다. + +--- + +## Available Commands + +| Command | Description | Needs Credential | +|---------|-------------|-----------------| +| `npm run build` | TypeScript → JavaScript 컴파일 | No | +| `npm run typecheck` | `tsc --noEmit` 타입 체크 | No | +| `npm run lint` | ESLint 정적 분석 | No | +| `npm test` | Vitest 단위 테스트 | No | +| `npm run dev` | ts-node 개발 모드 | No | + +--- + +## Tech Stack Summary + +| Component | Technology | Notes | +|-----------|-----------|-------| +| Language | TypeScript (ES2020, CommonJS) | `strict: true` | +| Runtime | Node.js 20+ | Node 22+ 권장 (native SQLite) | +| CLI | Commander + Inquirer + Chalk v4 | CJS 호환 버전 | +| Browser | Playwright (Chromium) | SKT/SK브로드밴드 SLA 측정 자동화 | +| Storage | node:sqlite / JSON fallback | 외부 DB 불필요 | +| HTTP | Axios | 알림, npm 업데이트 체크 | +| Config | YAML (js-yaml) | `~/.damn-my-slow-isp/config-skt.yaml`; v4 includes SKT terms acceptance | +| Lint | ESLint + typescript-eslint | `eslint.config.mjs` | +| Test | Vitest | `tests/` directory | diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa4c5c9 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# 🐌 damn-my-slow-skt + +**SKT/SK브로드밴드 인터넷 SLA 속도 미달을 자동으로 측정하고 감면 신청을 돕는 CLI 도구.** + +## 이게 뭔데 + +SK브로드밴드 유선 인터넷은 공식 Myspeed 사이트에서 인터넷 SLA 속도측정을 제공한다. 이 도구는 그 흐름을 Playwright로 실행하고, 결과를 로컬 DB에 남기며, SLA 미달 시 감면 처리까지 이어가도록 설계된 자동화 CLI다. + +> 현재 SK브로드밴드 Myspeed는 B world/T아이디/간편인증 기반 로그인 화면을 사용한다. 이전 provider의 셀렉터는 제거했고, SK브로드밴드 공식 진입점과 용어로 전환했다. 실제 감면 제출 셀렉터는 live 계정으로 검증해야 한다. + +## 시작하기 + +### 1. Node.js 설치 + +Node.js 20 이상이 필요합니다. + +```bash +node -v +npm -v +npx -v +``` + +### 2. 초기 설정 + +```bash +npx -y damn-my-slow-skt@latest init +``` + +설정 파일은 `~/.damn-my-slow-isp/config-skt.yaml`에 저장됩니다. 비밀번호는 로컬 YAML 파일에만 저장됩니다. + +초기 설정 중 SKT/SK브로드밴드 공식 이용약관 URL이 표시되며, 명시적으로 동의해야 설정 파일이 저장됩니다. 동의하지 않으면 `init`은 설정 파일을 만들지 않고 종료합니다. + +### 3. 로그인 세션 저장 + +SK브로드밴드 Myspeed는 B world/T아이디/간편인증 로그인이 필요합니다. 최초 1회는 브라우저를 열어 직접 인증하고, 세션을 로컬 파일로 저장합니다. + +```bash +npx -y damn-my-slow-skt@latest auth login +``` + +저장 위치는 설정 파일의 `auth_state_path`이며 기본값은 `~/.damn-my-slow-isp/auth-skt.json`입니다. 세션이 만료되면 같은 명령을 다시 실행하세요. + +```bash +npx -y damn-my-slow-skt@latest auth status +npx -y damn-my-slow-skt@latest auth clear +``` + +### 4. 실행 + +```bash +npx -y damn-my-slow-skt@latest run +``` + +- `--dry-run`: 측정만 하고 감면 신청은 생략 +- `--force`: 오늘 이미 완료했어도 강제로 다시 실행 +- `--debug`: 브라우저 창을 띄워 진행 과정을 직접 확인 + +## SKT/SK브로드밴드 SLA 기준 + +공식 확인 출처: + +- 인터넷 품질 측정: http://myspeed.skbroadband.com/ +- 인터넷 SLA 속도측정: http://myspeed.skbroadband.com/mesu/internet_sla.asp +- SK브로드밴드 이용약관: https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000 +- 전기통신 서비스 이용약관 PDF: https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf + +Myspeed SLA 안내 페이지에서 확인한 기준: + +- **판정**: 30분간 5회 이상 측정하여 측정치의 60% 이상이 최저속도(다운로드 속도)에 미달하면 당일 이용요금 감면 +- **대상**: PC 1대를 이용한 측정. 무선랜, 공유환경, 상품 제공속도보다 낮은 LAN카드 이용 및 설정 변경 등은 제외될 수 있음 +- **측정 사이트**: SK브로드밴드 Myspeed 인터넷 SLA 속도측정 메뉴 +- **후속 조치**: 최저속도 미달 시 품질 점검 및 더 나은 서비스 제공을 위해 TM 및 방문 점검을 실시할 수 있음 + +## 설정 바꾸기 + +`~/.damn-my-slow-isp/config-skt.yaml` 파일을 직접 편집합니다. + +```yaml +terms: + provider: "skt" + accepted: true + accepted_at: "2026-03-30T00:00:00.000Z" + version: "2026-03-30" + urls: + - "https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000" + - "https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf" + +schedule: + max_attempts: 10 + retry_interval_minutes: 120 + +notification: + discord_webhook: "" + telegram_bot_token: "" + +auth_state_path: "~/.damn-my-slow-isp/auth-skt.json" +``` + +`run` 명령은 `terms.accepted: true`, 현재 약관 `version`, 그리고 유효한 `accepted_at`이 없으면 실행하지 않습니다. 기존 설정은 대화형 `run` 또는 `init --force`에서 약관 동의를 기록하도록 마이그레이션됩니다. + +설정 변경 후 스케줄 재등록: + +```bash +npx -y damn-my-slow-skt@latest schedule install +``` + +## 요구사항 + +- Node.js 20+ +- SKT/B world 계정 또는 SK브로드밴드 회선 인증 수단 +- SKT/SK브로드밴드 공식 이용약관 동의 +- 최초 1회 `auth login`으로 저장한 로그인 세션 +- 유선(LAN) 연결 권장 +- Playwright Chromium + +## 개발 + +```bash +npm install +npx playwright install chromium +npm run typecheck +npm run lint +npm run build +npm test +``` + +| Component | Technology | +|-----------|------------| +| Language | TypeScript (ES2020, CommonJS, strict) | +| CLI | Commander + Inquirer + Chalk v4 | +| Browser | Playwright Chromium | +| Storage | node:sqlite / JSON fallback | +| Config | YAML — `~/.damn-my-slow-isp/config-skt.yaml` | diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..5fecd00 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,108 @@ +# damn-my-slow-skt + +SKT/SK브로드밴드 인터넷 SLA 속도 미달 시 요금 감면 자동화를 목표로 하는 CLI 도구. + +## 개요 + +SK브로드밴드는 Myspeed에서 인터넷 품질 측정과 인터넷 SLA 속도측정을 제공한다. 이 도구는 공식 측정 흐름을 자동 실행하고 결과를 저장하며, 미달 시 감면 처리까지 이어가도록 설계한다. + +## 핵심 기능 + +1. **SKT/B world 로그인 진입** (Playwright 기반 브라우저 자동화) +2. **SK브로드밴드 공식 SLA 속도 측정 실행** (myspeed.skbroadband.com) +3. **측정 결과 기록** (SQLite / JSON fallback) +4. **속도 미달 시 감면 신청 처리** +5. **SKT/SK브로드밴드 공식 이용약관 동의 기록** +6. **결과 리포트** (Discord/Telegram 알림 옵션) +7. **다회 측정** - 하루 최대 N회, 감면 성공 시 자동 스킵 +8. **업데이트 마이그레이션** - 버전 업 시 설정 변경 안내 + +## SK브로드밴드 SLA 측정 플로우 + +1. http://myspeed.skbroadband.com/mesu/internet_sla.asp 접속 +2. "SLA 속도측정 시작하기" 클릭 +3. B world/T아이디 또는 간편인증 로그인 +4. 회선/측정 환경 확인 +5. 5회 자동 측정 완료 대기 +6. 결과 파싱 → SLA pass/fail 판단 +7. fail 시 품질 점검/감면 처리 단계 진행 + +## SLA 기준 + +- 30분간 5회 이상 측정 +- 측정치의 60% 이상이 최저속도(다운로드 속도)에 미달하면 당일 이용요금 감면 +- 유선(LAN) 연결 권장, 무선랜/공유환경/부적합 LAN카드 등은 감면 제외 가능 + +## 기술 스택 + +- **언어**: TypeScript (Node.js 20+) +- **브라우저 자동화**: Playwright (headless Chromium) +- **스케줄링**: macOS launchd / Linux systemd timer / crontab +- **설정**: YAML (`~/.damn-my-slow-isp/config-skt.yaml`) +- **데이터 저장**: SQLite (Node 22+ built-in) / JSON fallback +- **알림**: Discord webhook / Telegram bot (선택) +- **배포**: npm registry (`npx damn-my-slow-skt`) + +## 설정 파일 (`~/.damn-my-slow-isp/config-skt.yaml`) + +```yaml +_config_version: 4 +credentials: + id: "사용자ID" + password: "비밀번호" +terms: + provider: "skt" + accepted: true + accepted_at: "2026-03-30T00:00:00.000Z" + version: "2026-03-30" + urls: + - "https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000" + - "https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf" +phone: "01012345678" +plan: + speed_mbps: 1000 +schedule: + time: "04:00" + timezone: "Asia/Seoul" + max_attempts: 10 + retry_interval_minutes: 120 + stop_on_complaint_success: true +notification: + discord_webhook: "" + telegram_bot_token: "" + telegram_chat_id: "" +headless: true +db_path: "~/.damn-my-slow-isp/history-skt.db" +``` + +`init`은 위 약관 URL을 표시하고 명시적 동의를 받은 뒤 설정을 저장한다. v4 마이그레이션도 같은 공식 URL을 표시하고 동의를 기록한다. `run`은 현재 SKT 약관 버전 동의가 없으면 필수 설정 누락으로 종료한다. + +## CLI 인터페이스 + +```bash +damn-my-slow-skt init +damn-my-slow-skt run +damn-my-slow-skt run --dry-run +damn-my-slow-skt run --force +damn-my-slow-skt config show +damn-my-slow-skt history +damn-my-slow-skt report +damn-my-slow-skt schedule install +damn-my-slow-skt schedule remove +``` + +## 프로젝트 구조 + +``` +src/ +├── index.ts +├── cli.ts +├── config.ts +├── db.ts +├── skt.ts +├── migration.ts +├── notify.ts +├── report.ts +├── scheduler.ts +└── updater.ts +``` diff --git a/bin/damn-my-slow-skt b/bin/damn-my-slow-skt new file mode 100755 index 0000000..8e1d6e9 --- /dev/null +++ b/bin/damn-my-slow-skt @@ -0,0 +1,4 @@ +#!/usr/bin/env node +'use strict'; + +require('../dist/index.js'); diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..152c6dc --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,38 @@ +# damn-my-slow-skt 설정 파일 예시 +# config-skt.yaml로 복사 후 사용: cp config.yaml.example ~/.damn-my-slow-isp/config-skt.yaml + +_config_version: 4 + +credentials: + id: "skt아이디@example.com" + password: "비밀번호" + +terms: + provider: "skt" + accepted: true + accepted_at: "2026-03-30T00:00:00.000Z" + version: "2026-03-30" + urls: + - "https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000" + - "https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf" + +phone: "01012345678" + +plan: + speed_mbps: 1000 # 계약 속도 (Mbps) + +schedule: + time: "04:00" + timezone: "Asia/Seoul" + max_attempts: 10 + retry_interval_minutes: 120 + stop_on_complaint_success: true + +notification: + discord_webhook: "" + telegram_bot_token: "" + telegram_chat_id: "" + +headless: true +db_path: "~/.damn-my-slow-isp/history-skt.db" +auth_state_path: "~/.damn-my-slow-isp/auth-skt.json" diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..21eecdc --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,33 @@ +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; + +export default [ + { + files: ["src/**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { + project: "./tsconfig.json", + }, + }, + plugins: { + "@typescript-eslint": tsPlugin, + }, + rules: { + // Error-level: catch real bugs + "@typescript-eslint/no-misused-promises": "error", + "@typescript-eslint/no-floating-promises": "error", + "no-console": "off", // CLI tool — console is the UI + + // Warn-level: style preferences + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "warn", + + // Off: too noisy for this codebase + "@typescript-eslint/no-require-imports": "off", + }, + }, + { + ignores: ["dist/", "node_modules/", "tests/"], + }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b96762a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3641 @@ +{ + "name": "damn-my-slow-skt", + "version": "0.5.26", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "damn-my-slow-skt", + "version": "0.5.26", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "chalk": "^4.1.2", + "cli-table3": "^0.6.3", + "commander": "^12.0.0", + "inquirer": "^8.2.6", + "js-yaml": "^4.1.0", + "playwright": "^1.52.0" + }, + "bin": { + "damn-my-slow-skt": "bin/damn-my-slow-skt" + }, + "devDependencies": { + "@action-validator/cli": "^0.6.0", + "@types/inquirer": "^8.2.10", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.58.1", + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^10.2.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0", + "vitest": "^4.1.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@action-validator/cli": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@action-validator/cli/-/cli-0.6.0.tgz", + "integrity": "sha512-Z8TYOK4GqUIpI0UuspUJPB6dhr0niTumhwI5iiZVqFRXm4u05bZawnFKltpvoFUfJg9mHbbIBlleqsRJAgl53Q==", + "dev": true, + "license": "GPL-3.0-only", + "dependencies": { + "chalk": "5.2.0" + }, + "bin": { + "action-validator": "cli.mjs" + }, + "peerDependencies": { + "@action-validator/core": "0.6.0" + } + }, + "node_modules/@action-validator/cli/node_modules/chalk": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz", + "integrity": "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@action-validator/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@action-validator/core/-/core-0.6.0.tgz", + "integrity": "sha512-tPglwCr8Mm8SWzwnVewwFmqRx91F0WvMsM0BRAqH4CLalyGndm53Xvp+UcUSzswpk1wkjIDYI7RyEhWMLyPkig==", + "dev": true, + "license": "GPL-3.0-only", + "peer": true + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.4.tgz", + "integrity": "sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.4", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.4.tgz", + "integrity": "sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.0.tgz", + "integrity": "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.4.tgz", + "integrity": "sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz", + "integrity": "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/inquirer": { + "version": "8.2.12", + "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.12.tgz", + "integrity": "sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/through": "*", + "rxjs": "^7.2.0" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/through": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7489754 --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "damn-my-slow-skt", + "version": "0.5.26", + "description": "SKT/SK브로드밴드 인터넷 SLA 속도 미달 시 요금 감면을 자동화하는 CLI 도구", + "keywords": [ + "skt", + "skbroadband", + "sla", + "internet", + "speed", + "automation", + "cli" + ], + "homepage": "https://github.com/kargnas/damn-my-slow-skt", + "bugs": { + "url": "https://github.com/kargnas/damn-my-slow-skt/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/kargnas/damn-my-slow-skt.git" + }, + "license": "MIT", + "bin": { + "damn-my-slow-skt": "bin/damn-my-slow-skt" + }, + "main": "dist/index.js", + "files": [ + "bin", + "dist", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc", + "dev": "ts-node src/index.ts", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "axios": "^1.6.0", + "chalk": "^4.1.2", + "cli-table3": "^0.6.3", + "commander": "^12.0.0", + "inquirer": "^8.2.6", + "js-yaml": "^4.1.0", + "playwright": "^1.52.0" + }, + "devDependencies": { + "@action-validator/cli": "^0.6.0", + "@types/inquirer": "^8.2.10", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.58.1", + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^10.2.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0", + "vitest": "^4.1.3" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..435efbd --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,1075 @@ +/** + * CLI 커맨드 정의 - commander 기반 + * + * Usage: + * damn-my-slow-skt init # 초기 설정 (~/.damn-my-slow-isp/config-skt.yaml) + * damn-my-slow-skt run # 측정 + 감면 신청 (설정에 따라 다회 반복) + * damn-my-slow-skt run --once # 1회만 측정 + * damn-my-slow-skt run --dry-run # 측정만 (감면 신청 생략) + * damn-my-slow-skt history # 이력 조회 + * damn-my-slow-skt report # 요약 리포트 + * damn-my-slow-skt schedule install # 스케줄 등록 + * damn-my-slow-skt schedule remove # 스케줄 제거 + */ + +import { Command } from "commander"; +import inquirer from "inquirer"; +import chalk from "chalk"; +import path from "path"; +import { execSync } from "child_process"; +import fs from "fs"; + +import { + loadConfig, + saveConfig, + getDefaultConfig, + DEFAULT_CONFIG_PATH, + DATA_DIR, + Config, + SKT_TERMS_URLS, + SKT_TERMS_VERSION, + validateRequiredFields, +} from "./config"; +import { + isInsideDocker, + isDockerAvailable, + isSharedLibraryError, + reExecInDocker, + printDockerInstallGuide, +} from "./docker"; +import { SpeedDatabase } from "./db"; +import { SKTProvider, SpeedTestResult } from "./skt"; +import { sendNotifications } from "./notify"; +import { printHistory, printStats } from "./report"; +import { installSchedule, removeSchedule, getPlatform } from "./scheduler"; +import { checkForUpdates } from "./updater"; +import { checkAndRunMigrations, CURRENT_CONFIG_VERSION } from "./migration"; + +// package.json에서 버전 읽기 +const pkg = require("../package.json") as { version: string; name: string }; + +export function buildCli(): Command { + const program = new Command(); + + program + .name("damn-my-slow-skt") + .description("🐌 느린 SKT/SK브로드밴드 인터넷? 자동으로 환불받자.") + .version(pkg.version) + .option("--no-update-check", "자동 업데이트 체크 비활성화"); + + // ───────────────────────────────────── + // init + // ───────────────────────────────────── + program + .command("init") + .description("초기 설정: 설정 파일 생성 + 자동 스케줄 설치 여부 질문") + .option("-c, --config ", "설정 파일 경로", DEFAULT_CONFIG_PATH) + .option("-f, --force", "기존 파일 덮어쓰기", false) + .action(async (opts: { config: string; force: boolean }) => { + const configPath = opts.config; + + if (fs.existsSync(configPath) && !opts.force) { + // 기존 설정 파일이 있으면 마이그레이션 체크 후 종료 + let cfg = loadConfig(configPath); + cfg = await checkAndRunMigrations(cfg, configPath, { + interactive: true, + }); + + if (cfg._config_version >= CURRENT_CONFIG_VERSION) { + const missingFields = validateRequiredFields(cfg); + if (missingFields.length > 0) { + console.log(chalk.yellow(`⚠️ 설정 파일은 있지만 필수 항목이 누락되었습니다:`)); + for (const field of missingFields) { + console.log(chalk.yellow(` • ${field}`)); + } + console.log( + `\n'npx -y damn-my-slow-skt@latest init --force' 명령으로 설정을 다시 진행하세요.`, + ); + } else { + console.log( + chalk.green(`✅ 설정이 최신 상태입니다. (v${cfg._config_version})`), + ); + console.log(chalk.dim(` ${configPath}`)); + console.log( + chalk.dim("\n 새로 설정하려면 --force 옵션을 사용하세요."), + ); + } + } + return; + } + + let existing: Config | null = null; + if (fs.existsSync(configPath)) { + try { existing = loadConfig(configPath); } catch { existing = null; } + } + + console.log(chalk.cyan("\n🐌 damn-my-slow-skt 초기 설정\n")); + console.log( + chalk.yellow("⚠️ SKT/SK브로드밴드 SLA 측정은 유선(LAN) 연결에서만 유효합니다."), + ); + console.log( + chalk.yellow(" Wi-Fi로 측정하면 감면 신청이 거부될 수 있습니다."), + ); + if (existing) { + console.log(chalk.dim("\n 기존 설정값이 표시됩니다. 엔터를 누르면 기존 값을 유지합니다.")); + } + printSpeedAgentInstallGuide(); + + const existingAcceptedCurrentTerms = existing?.terms.provider === "skt" + && existing.terms.accepted === true + && existing.terms.version === SKT_TERMS_VERSION + && !Number.isNaN(new Date(existing.terms.accepted_at).getTime()) + && new Date(existing.terms.accepted_at).toISOString() === existing.terms.accepted_at; + + console.log(chalk.cyan("\n📋 SKT/SK브로드밴드 공식 이용약관 확인")); + console.log(" 이 도구를 사용하기 전에 아래 공식 약관을 확인하고 동의해야 합니다."); + for (const url of SKT_TERMS_URLS) { + console.log(chalk.dim(` - ${url}`)); + } + + const termsAnswer = await inquirer.prompt([ + { + type: "confirm", + name: "acceptTerms", + message: "위 SKT/SK브로드밴드 공식 이용약관을 확인했고 동의합니다.", + default: existingAcceptedCurrentTerms, + }, + ]); + + if (!termsAnswer.acceptTerms) { + console.log(chalk.yellow("\n⚠️ SKT/SK브로드밴드 공식 이용약관 동의가 필요합니다.")); + console.log(chalk.dim(" 동의하지 않아 설정 파일을 저장하지 않았습니다.")); + return; + } + + // 1단계: SKT/B world 아이디 먼저 수집 + const idAnswer = await inquirer.prompt([ + { + type: "input", + name: "id", + message: "SKT/B world 아이디 (이메일 또는 ID):", + default: existing?.credentials.id || undefined, + validate: (v: string) => v.trim() !== "" || "아이디를 입력하세요.", + }, + ]); + + // 비밀번호 입력 직전 — 입력하면서 볼 수 있도록 소스 코드 링크 안내 (회색) + // 비밀번호는 로컬 YAML 설정 파일에만 저장되며, 외부 서버로 전송되지 않음 + console.log(chalk.dim(" 🔍 비밀번호는 로컬에만 저장됩니다. 무서우시면 직접 확인하세요:")); + console.log(chalk.dim(" 저장 방식: https://github.com/kargnas/damn-my-slow-skt/blob/main/src/config.ts#L124")); + console.log(chalk.dim(" 로그인 로직: https://github.com/kargnas/damn-my-slow-skt/blob/main/src/skt.ts#L341")); + console.log(chalk.dim(" 이 화면 코드: https://github.com/kargnas/damn-my-slow-skt/blob/main/src/cli.ts")); + + // 2단계: 비밀번호 수집 + const pwAnswer = await inquirer.prompt([ + { + type: "password", + name: "password", + message: existing?.credentials.password + ? "SKT/B world 비밀번호 (엔터 시 기존 비밀번호 사용):" + : "SKT/B world 비밀번호:", + mask: "*", + validate: (v: string) => { + if (!v.trim() && existing?.credentials.password) return true; + return v.trim() !== "" || "비밀번호를 입력하세요."; + }, + }, + ]); + + // 3단계: 나머지 설정 수집 + const restAnswers = await inquirer.prompt([ + { + type: "input", + name: "phone", + message: "연락처 (휴대폰 번호 — 이의신청 시 필요):", + default: existing?.phone || undefined, + validate: (v: string) => { + if (!v.trim()) return "연락처를 입력하세요. 이의신청 시 필수입니다."; + return /^01[0-9]{8,9}$/.test(v.replace(/-/g, "")) || "올바른 휴대폰 번호를 입력하세요. (예: 01012345678)"; + }, + }, + { + type: "input", + name: "discord_webhook", + message: "Discord 웹훅 URL (없으면 엔터):", + default: existing?.notification.discord_webhook || "", + }, + { + type: "input", + name: "telegram_token", + message: "Telegram 봇 토큰 (없으면 엔터):", + default: existing?.notification.telegram_bot_token || "", + }, + { + type: "confirm", + name: "headless", + message: "브라우저를 숨김 모드로 실행할까요?", + default: existing?.headless ?? true, + }, + ]); + + // 세 단계 응답 합산 — 이후 코드는 기존 answers 변수 구조 그대로 사용 + const answers = { ...idAnswer, ...pwAnswer, ...restAnswers }; + + let telegramChatId = existing?.notification.telegram_chat_id || ""; + if (answers.telegram_token) { + const chatAnswer = await inquirer.prompt([ + { + type: "input", + name: "chat_id", + message: "Telegram 채팅 ID:", + default: telegramChatId || "", + }, + ]); + telegramChatId = chatAnswer.chat_id; + } + + const defaults = getDefaultConfig(); + const cfg: Config = { + _config_version: defaults._config_version, + credentials: { + id: answers.id, + password: answers.password || existing?.credentials.password || "", + }, + terms: { + provider: "skt", + accepted: true, + accepted_at: existingAcceptedCurrentTerms + ? existing?.terms.accepted_at || new Date().toISOString() + : new Date().toISOString(), + version: SKT_TERMS_VERSION, + urls: [...SKT_TERMS_URLS], + }, + phone: answers.phone ? answers.phone.replace(/-/g, "") : "", + plan: { speed_mbps: existing?.plan.speed_mbps || defaults.plan.speed_mbps }, + schedule: existing?.schedule || { + time: "04:00", + timezone: "Asia/Seoul", + max_attempts: defaults.schedule.max_attempts, + retry_interval_minutes: defaults.schedule.retry_interval_minutes, + stop_on_complaint_success: + defaults.schedule.stop_on_complaint_success, + }, + notification: { + discord_webhook: answers.discord_webhook, + telegram_bot_token: answers.telegram_token, + telegram_chat_id: telegramChatId, + }, + headless: answers.headless, + db_path: existing?.db_path || defaults.db_path, + auth_state_path: existing?.auth_state_path || defaults.auth_state_path, + }; + + // 설정 파일 저장 + const dir = path.dirname(configPath); + if (dir && dir !== ".") fs.mkdirSync(dir, { recursive: true }); + saveConfig(cfg, configPath); + + console.log(`\n${chalk.green(`✅ 설정 파일 생성 완료: ${configPath}`)}`); + console.log(chalk.dim(`주의: ${configPath} 에는 비밀번호가 포함됩니다.`)); + console.log( + chalk.dim( + `기본 설정: 하루 최대 ${cfg.schedule.max_attempts}회, ${cfg.schedule.retry_interval_minutes}분 간격 측정 (감면 성공 시 중단)`, + ), + ); + + // 자동 스케줄 설치 여부 물어보기 + const platform = getPlatform(); + if (platform !== "windows" && platform !== "unknown") { + const { installSched } = await inquirer.prompt([ + { + type: "confirm", + name: "installSched", + message: "매일 자동으로 속도 측정을 실행할까요?", + default: true, + }, + ]); + + if (installSched) { + try { + installSchedule(cfg, configPath); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.log(""); + console.log( + chalk.yellow( + "⚠️ 설정 저장은 완료되었으나, 자동 스케줄 등록에 실패했습니다.", + ), + ); + console.log(chalk.dim(` 원인: ${err.message}`)); + console.log(""); + console.log(" 수동으로 실행하려면:"); + console.log( + chalk.bold( + ` npx --yes damn-my-slow-skt run --config ${configPath}`, + ), + ); + console.log(""); + console.log(" 스케줄을 다시 등록하려면:"); + console.log( + chalk.bold( + ` npx --yes damn-my-slow-skt schedule install --config ${configPath}`, + ), + ); + } + } + } else if (platform === "windows") { + console.log("\nWindows에서는 작업 스케줄러를 수동으로 설정하세요:"); + console.log( + ` npx -y damn-my-slow-skt@latest schedule install --config ${configPath}`, + ); + } + + console.log(chalk.dim("\n지금 테스트하려면 실행해보세요:")); + console.log(chalk.bold(" npx -y damn-my-slow-skt@latest run")); + + await askForStar(); + }); + + // ───────────────────────────────────── + // run - 1회 측정 후 종료. cron/launchd가 다회 트리거. + // 매 실행 시 오늘 이미 감면 성공했는지 DB에서 확인. + // ───────────────────────────────────── + program + .command("run") + .description("1회 측정 + 감면 신청 (스케줄러가 다회 트리거)") + .option("-c, --config ", "설정 파일 경로", DEFAULT_CONFIG_PATH) + .option("--dry-run", "측정만 하고 감면 신청 생략", false) + .option("--force", "오늘 완료 여부 무시하고 강제 실행", false) + .option("-v, --verbose", "상세 로그 출력", false) + .option("--screenshot", "측정 완료 후 스크린샷 저장", false) + .option( + "--debug", + "브라우저 창을 열고 느린 모드(slowMo)로 실행 — 문제 진단용", + false, + ) + .action( + async (opts: { + config: string; + dryRun: boolean; + force: boolean; + verbose: boolean; + screenshot: boolean; + debug: boolean; + }) => { + // 업데이트 체크 + const noUpdateCheck = program.opts().noUpdateCheck as + | boolean + | undefined; + await checkForUpdates(pkg.version, { noUpdateCheck }); + + let cfg: Config; + try { + cfg = loadConfig(opts.config); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(`❌ ${err.message}`)); + process.exit(1); + } + + // 마이그레이션 체크 (업데이트 후 설정 변경 안내) + cfg = await checkAndRunMigrations(cfg, opts.config); + + const missingFields = validateRequiredFields(cfg); + if (missingFields.length > 0) { + console.error(chalk.red("❌ 필수 설정이 누락되었습니다:")); + for (const field of missingFields) { + console.error(chalk.red(` • ${field}`)); + } + console.error( + `\n'npx -y damn-my-slow-skt@latest init' 명령으로 설정을 다시 진행하세요.`, + ); + process.exit(1); + } + + // ── 오늘 상태 체크 ── + const db = new SpeedDatabase(cfg.db_path); + const todayRecords = db.getTodayRecords(cfg.schedule.timezone); + const todayCount = todayRecords.length; + const maxAttempts = cfg.schedule.max_attempts || 10; + const alreadySucceeded = db.hasTodayComplaintSuccess( + cfg.schedule.timezone, + ); + const isInteractive = process.stdout.isTTY === true; + + if (!opts.force) { + // 오늘 감면 성공 완료 + if ( + alreadySucceeded && + cfg.schedule.stop_on_complaint_success !== false + ) { + if (isInteractive) { + console.log( + chalk.green( + `\n✅ 오늘 이미 감면 신청에 성공했습니다. (${todayCount}회 측정)`, + ), + ); + const { proceed } = await inquirer.prompt([ + { + type: "confirm", + name: "proceed", + message: "그래도 추가 측정하시겠습니까?", + default: false, + }, + ]); + if (!proceed) { + db.close(); + return; + } + } else { + // non-interactive (cron/launchd): 스킵 + --force 안내 + console.log( + `[skip] 오늘 감면 성공 완료 (${todayCount}회 측정). 스킵합니다.`, + ); + console.log( + ` 강제 실행하려면: npx -y damn-my-slow-skt@latest run --force`, + ); + db.close(); + return; + } + } + + // 오늘 최대 횟수 도달 + if (todayCount >= maxAttempts) { + if (isInteractive) { + console.log( + chalk.yellow( + `\n⚠️ 오늘 이미 ${todayCount}회 측정했습니다. (최대 ${maxAttempts}회)`, + ), + ); + const { proceed } = await inquirer.prompt([ + { + type: "confirm", + name: "proceed", + message: "최대 횟수를 초과하여 추가 측정하시겠습니까?", + default: false, + }, + ]); + if (!proceed) { + db.close(); + return; + } + } else { + console.log( + `[skip] 오늘 ${todayCount}/${maxAttempts}회 완료. 스킵합니다.`, + ); + console.log( + ` 강제 실행하려면: npx -y damn-my-slow-skt@latest run --force`, + ); + db.close(); + return; + } + } + } + + // ── 측정 실행 ── + console.log(chalk.cyan("\n🐌 damn-my-slow-skt 실행")); + console.log( + `SKT/SK브로드밴드 | ${opts.dryRun ? "dry-run 모드" : "감면 신청 활성화"}`, + ); + console.log( + chalk.dim("유선(LAN) 연결 확인 필수 — Wi-Fi 측정은 SLA 인정 불가"), + ); + if (isInteractive) { + printSpeedAgentInstallGuide(); + } + if (todayCount > 0) { + console.log( + chalk.dim( + `오늘 ${todayCount + 1}번째 측정 (최대 ${maxAttempts}회)`, + ), + ); + } + console.log(""); + + const provider = new SKTProvider(cfg); + const measuredAt = new Date().toISOString(); + + const localTime = new Date(measuredAt).toLocaleString('ko-KR', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }); + console.log(`측정 시작: ${localTime}`); + if (opts.debug) { + console.log( + chalk.yellow( + "🔍 디버그 모드: 브라우저 창을 열고 느린 속도로 실행합니다 (slowMo + DevTools)", + ), + ); + console.log( + chalk.dim( + " config 파일의 headless 설정을 무시하고 이번 실행만 임시로 끕니다.", + ), + ); + } else if (cfg.headless) { + console.log(chalk.dim("headless 모드로 실행합니다")); + } else { + console.log(chalk.dim("브라우저 창이 열립니다 (headless=false)")); + } + + let result: SpeedTestResult; + try { + result = await provider.run({ dryRun: opts.dryRun, debug: opts.debug }); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + + // Linux에서 shared library 누락 → Docker 자동 전환 + if (process.platform === 'linux' && isSharedLibraryError(err)) { + if (isInsideDocker()) { + console.error(chalk.red('\n❌ Docker 내에서도 Chromium 실행 실패')); + console.error(chalk.dim(` ${err.message}`)); + db.close(); + process.exit(1); + } + + if (isDockerAvailable()) { + db.close(); + reExecInDocker(); + } + + printDockerInstallGuide(err.message); + db.close(); + process.exit(1); + } + + throw e; + } + + const record = { + isp: "skt", + measured_at: measuredAt, + download_mbps: result.download_mbps, + upload_mbps: result.upload_mbps, + ping_ms: result.ping_ms, + sla_result: result.sla_result, + complaint_filed: result.complaint_filed, + complaint_result: result.complaint_result, + raw_data: JSON.stringify(result.raw_data), + error: result.error, + }; + + db.save(record); + db.close(); + + printRunResult(record, cfg.plan.speed_mbps); + await sendNotifications(cfg, record); + + if (result.complaint_result === "success") { + console.log( + chalk.green( + "\n🎉 감면 신청 성공! 다음 스케줄 실행 시 자동으로 스킵됩니다.", + ), + ); + } + + if (result.error) { + console.error(`\n${chalk.red(`⚠️ 오류 발생: ${result.error}`)}`); + + // 디버그 모드가 아니었다면 진단용 재실행 옵션 제공. + // 이슈 #3 댓글들처럼 환경별 엣지 케이스(새 기기 등록, 다회선 주소지 선택, + // 회선 미보유, 속도측정 프로그램 미설치 등)는 브라우저를 직접 관찰해야 + // 원인을 파악할 수 있는 경우가 많음. + if (!opts.debug) { + if (isInteractive) { + // 인터랙티브 TTY: 바로 디버그 재실행 여부를 물어봄 + console.error(""); + const { retryDebug } = await inquirer.prompt([ + { + type: "confirm", + name: "retryDebug", + message: + "브라우저 창을 열어서 어디서 막혔는지 직접 확인해보시겠습니까? (디버그 모드)", + default: true, + }, + ]); + + if (retryDebug) { + console.log(chalk.yellow("\n🔍 디버그 모드로 재실행합니다...")); + console.log(""); + + const debugProvider = new SKTProvider(cfg); + try { + // 디버그 재실행도 일반 실행과 동일하게 이력 저장 + 감면 신청을 수행. + // dryRun은 원 호출의 opt를 그대로 따라가고, debug만 강제 true. + const debugMeasuredAt = new Date().toISOString(); + const debugResult = await debugProvider.run({ + dryRun: opts.dryRun, + debug: true, + }); + + const debugRecord = { + isp: "skt", + measured_at: debugMeasuredAt, + download_mbps: debugResult.download_mbps, + upload_mbps: debugResult.upload_mbps, + ping_ms: debugResult.ping_ms, + sla_result: debugResult.sla_result, + complaint_filed: debugResult.complaint_filed, + complaint_result: debugResult.complaint_result, + raw_data: JSON.stringify(debugResult.raw_data), + error: debugResult.error, + }; + + const debugDb = new SpeedDatabase(cfg.db_path); + debugDb.save(debugRecord); + debugDb.close(); + + printRunResult(debugRecord, cfg.plan.speed_mbps); + await sendNotifications(cfg, debugRecord); + + if (debugResult.complaint_result === "success") { + console.log( + chalk.green( + "\n🎉 감면 신청 성공! 다음 스케줄 실행 시 자동으로 스킵됩니다.", + ), + ); + } + + // 재실행이 정상 종료된 경우 1차 실행의 에러만으로 exit(1) 하지 않음. + if (!debugResult.error) { + return; + } + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(`디버그 재실행 중 예외: ${err.message}`)); + } + } + } else { + // non-interactive(cron/launchd): 텍스트 안내만 출력 + console.error(""); + console.error( + chalk.yellow( + "💡 원인을 확인하려면 터미널에서 아래 명령으로 다시 실행해보세요:", + ), + ); + console.error( + chalk.bold( + ` npx -y damn-my-slow-skt@latest run --debug${opts.dryRun ? " --dry-run" : ""}`, + ), + ); + console.error( + chalk.dim( + " (브라우저 창이 열리고, 에러 발생 시 상태를 확인할 수 있도록 대기합니다)", + ), + ); + } + } + + process.exit(1); + } + }, + ); + + // ───────────────────────────────────── + // config show + // ───────────────────────────────────── + const configCmd = program.command("config").description("설정 관리"); + + configCmd + .command("show") + .description("현재 설정 확인") + .option("-c, --config ", "설정 파일 경로", DEFAULT_CONFIG_PATH) + .action((opts: { config: string }) => { + let cfg: Config; + try { + cfg = loadConfig(opts.config); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(err.message)); + process.exit(1); + } + + console.log(chalk.cyan("\n⚙️ 현재 설정 (SKT/SK브로드밴드)\n")); + console.log(`설정 버전: v${cfg._config_version}`); + console.log(`계정 ID: ${cfg.credentials.id}`); + console.log(`비밀번호: ${"*".repeat(cfg.credentials.password.length)}`); + console.log( + `약관 동의: ${cfg.terms.accepted ? "동의함" : "미동의"} (${cfg.terms.version})`, + ); + if (cfg.terms.accepted_at) { + console.log(`약관 동의 시각: ${cfg.terms.accepted_at}`); + } + console.log(`계약 속도: ${cfg.plan.speed_mbps} Mbps`); + console.log(`첫 측정: ${cfg.schedule.time} (${cfg.schedule.timezone})`); + console.log( + `최대 측정: ${cfg.schedule.max_attempts}회/일 | ${cfg.schedule.retry_interval_minutes}분 간격`, + ); + console.log( + `감면 성공 시: ${cfg.schedule.stop_on_complaint_success ? "중단" : "계속 측정"}`, + ); + console.log( + `Discord 웹훅: ${cfg.notification.discord_webhook ? "설정됨" : "없음"}`, + ); + console.log( + `Telegram: ${cfg.notification.telegram_bot_token ? "설정됨" : "없음"}`, + ); + console.log(`Headless 모드: ${cfg.headless}`); + console.log(`DB 경로: ${cfg.db_path}`); + console.log(`로그인 세션: ${cfg.auth_state_path}`); + }); + + // ───────────────────────────────────── + // auth + // ───────────────────────────────────── + const authCmd = program.command("auth").description("B world/T아이디 로그인 세션 관리"); + + authCmd + .command("login") + .description("브라우저에서 로그인한 뒤 headless 실행용 세션 저장") + .option("-c, --config ", "설정 파일 경로", DEFAULT_CONFIG_PATH) + .action(async (opts: { config: string }) => { + let cfg: Config; + try { + cfg = loadConfig(opts.config); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(`❌ ${err.message}`)); + process.exit(1); + } + + const provider = new SKTProvider(cfg); + try { + await provider.login(); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(`❌ 로그인 세션 저장 실패: ${err.message}`)); + process.exit(1); + } + }); + + authCmd + .command("status") + .description("저장된 로그인 세션 상태 확인") + .option("-c, --config ", "설정 파일 경로", DEFAULT_CONFIG_PATH) + .action((opts: { config: string }) => { + let cfg: Config; + try { + cfg = loadConfig(opts.config); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(`❌ ${err.message}`)); + process.exit(1); + } + + if (!fs.existsSync(cfg.auth_state_path)) { + console.log(chalk.yellow("⚠️ 저장된 로그인 세션이 없습니다.")); + console.log(chalk.dim(` 경로: ${cfg.auth_state_path}`)); + console.log(" 먼저 실행: npx -y damn-my-slow-skt@latest auth login"); + return; + } + + const stat = fs.statSync(cfg.auth_state_path); + console.log(chalk.green("✅ 로그인 세션 파일이 있습니다.")); + console.log(chalk.dim(` 경로: ${cfg.auth_state_path}`)); + console.log(chalk.dim(` 수정: ${stat.mtime.toLocaleString("ko-KR")}`)); + console.log(chalk.dim(" 실제 만료 여부는 SK브로드밴드가 결정하므로, 만료되면 auth login을 다시 실행하세요.")); + }); + + authCmd + .command("clear") + .description("저장된 로그인 세션 삭제") + .option("-c, --config ", "설정 파일 경로", DEFAULT_CONFIG_PATH) + .action((opts: { config: string }) => { + let cfg: Config; + try { + cfg = loadConfig(opts.config); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(`❌ ${err.message}`)); + process.exit(1); + } + + if (!fs.existsSync(cfg.auth_state_path)) { + console.log(chalk.yellow("⚠️ 삭제할 로그인 세션이 없습니다.")); + console.log(chalk.dim(` 경로: ${cfg.auth_state_path}`)); + return; + } + + fs.unlinkSync(cfg.auth_state_path); + console.log(chalk.green("✅ 로그인 세션을 삭제했습니다.")); + console.log(chalk.dim(` 경로: ${cfg.auth_state_path}`)); + }); + + // ───────────────────────────────────── + // history + // ───────────────────────────────────── + program + .command("history") + .description("측정 이력 조회") + .option("-c, --config ", "설정 파일 경로", DEFAULT_CONFIG_PATH) + .option("-n, --limit ", "표시할 이력 수", "20") + .option("-m, --month ", "월 필터") + .action((opts: { config: string; limit: string; month?: string }) => { + let cfg: Config; + try { + cfg = loadConfig(opts.config); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(err.message)); + process.exit(1); + } + + const db = new SpeedDatabase(cfg.db_path); + const records = db.getHistory(parseInt(opts.limit) || 20, opts.month); + db.close(); + printHistory(records); + }); + + // ───────────────────────────────────── + // report + // ───────────────────────────────────── + program + .command("report") + .description("요약 리포트") + .option("-c, --config ", "설정 파일 경로", DEFAULT_CONFIG_PATH) + .option("-m, --month ", "월 필터") + .action((opts: { config: string; month?: string }) => { + let cfg: Config; + try { + cfg = loadConfig(opts.config); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(err.message)); + process.exit(1); + } + + const db = new SpeedDatabase(cfg.db_path); + const stats = db.getStats(opts.month); + db.close(); + printStats(stats); + }); + + // ───────────────────────────────────── + // schedule + // ───────────────────────────────────── + const schedCmd = program + .command("schedule") + .description("자동 실행 스케줄 관리"); + + schedCmd + .command("install") + .description("자동 실행 스케줄 등록") + .option("-c, --config ", "설정 파일 경로", DEFAULT_CONFIG_PATH) + .action((opts: { config: string }) => { + let cfg: Config; + try { + cfg = loadConfig(opts.config); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(err.message)); + process.exit(1); + } + + try { + installSchedule(cfg, opts.config); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(`❌ 스케줄 설치 실패: ${err.message}`)); + process.exit(1); + } + }); + + schedCmd + .command("remove") + .description("자동 실행 스케줄 제거") + .action(() => { + try { + removeSchedule(); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(`❌ 스케줄 제거 실패: ${err.message}`)); + process.exit(1); + } + }); + + return program; +} + +// ─── 속도 바 시각화 ───────────────────────────────────────────── + +/** 터미널 너비에 맞춘 속도 게이지 바 */ +function speedBar(mbps: number, maxMbps: number, width = 30): string { + const ratio = Math.min(mbps / maxMbps, 1); + const filled = Math.round(ratio * width); + const empty = width - filled; + + // 속도 비율에 따른 색상: <30% 빨강, <50% 노랑, >=50% 초록 + const colorFn = + ratio < 0.3 ? chalk.red : ratio < 0.5 ? chalk.yellow : chalk.green; + const bar = colorFn("█".repeat(filled)) + chalk.gray("░".repeat(empty)); + const pct = `${(ratio * 100).toFixed(0)}%`; + + return `${bar} ${chalk.bold(`${mbps.toFixed(1)}`)} Mbps ${chalk.dim(`(${pct})`)}`; +} + +function printRunResult( + record: { + sla_result: string; + download_mbps: number; + upload_mbps: number; + ping_ms: number; + complaint_filed: boolean; + complaint_result: string; + }, + contractSpeed = 1000, +): void { + const isFail = record.sla_result === "fail"; + const isPass = record.sla_result === "pass"; + + // 상단 구분선 + const headerColor = isFail ? chalk.red : isPass ? chalk.green : chalk.yellow; + const headerIcon = isFail ? "❌" : isPass ? "✅" : "⚠️"; + const headerText = isFail ? "SLA 미달" : isPass ? "SLA 통과" : "SLA 불명"; + + console.log(""); + console.log(headerColor(` ┌${"─".repeat(46)}┐`)); + console.log( + headerColor(` │`) + + ` ${headerIcon} ${chalk.bold(headerText)}` + + " ".repeat(46 - headerText.length * 2 - 6) + + headerColor("│"), + ); + console.log(headerColor(` ├${"─".repeat(46)}┤`)); + + // 속도 게이지 + console.log( + headerColor(" │") + + ` ⬇ 다운로드 ${speedBar(record.download_mbps, contractSpeed, 20)}` + + headerColor(" │"), + ); + + // 이의신청 결과 + if ( + record.complaint_filed || + (isFail && record.complaint_result === "skipped") + ) { + console.log(headerColor(` ├${"─".repeat(46)}┤`)); + + if (record.complaint_result === "success") { + console.log( + headerColor(" │") + + chalk.green(" 🎉 감면 신청 성공!") + + " ".repeat(27) + + headerColor("│"), + ); + } else if (record.complaint_result === "failed") { + console.log( + headerColor(" │") + + chalk.red(" ⚠️ 감면 신청 실패") + + " ".repeat(27) + + headerColor("│"), + ); + } else if (record.complaint_result === "skipped") { + console.log( + headerColor(" │") + + chalk.dim(" 📋 감면 신청 생략 (dry-run)") + + " ".repeat(19) + + headerColor("│"), + ); + } + } + + console.log(headerColor(` └${"─".repeat(46)}┘`)); +} + +// ─── SK브로드밴드 속도측정 프로그램 설치 안내 ──────────────────────────────── + +/** + * SKT/SK브로드밴드 SLA 측정을 위해 myspeed.skbroadband.com의 속도측정 에이전트 설치가 필요. + * macOS: SK브로드밴드 Myspeed 설치 프로그램, Windows: 사이트에서 직접 설치. + */ +function printSpeedAgentInstallGuide(): void { + const platform = process.platform; + + if (platform === "darwin") { + console.log(chalk.yellow("\n📦 SK브로드밴드 속도측정 프로그램 사전 설치 필요")); + console.log( + chalk.dim(" macOS: 아래 링크에서 프로그램을 설치하세요 (최초 1회)"), + ); + console.log(chalk.bold(" https://myspeed.skbroadband.com/file/SK브로드밴드 Myspeed 설치 프로그램")); + console.log(""); + } else if (platform === "win32") { + console.log(chalk.yellow("\n📦 SK브로드밴드 속도측정 프로그램 사전 설치 필요")); + console.log( + chalk.dim(" Windows: 아래 절차를 따라 설치하세요 (최초 1회)"), + ); + console.log(chalk.dim(" 1. https://myspeed.skbroadband.com 접속")); + console.log(chalk.dim(" 2. 속도측정 → 품질보증(SLA) 테스트 클릭")); + console.log(chalk.dim(" 3. 안내에 따라 속도측정 프로그램 설치")); + console.log(""); + } else { + console.log( + chalk.yellow("\n⚠️ SK브로드밴드 속도측정 프로그램이 Linux를 공식 지원하지 않습니다."), + ); + if (isInsideDocker()) { + console.log( + chalk.dim(" 🐳 Docker 컨테이너에서 실행 중 — 웹 측정을 시도합니다."), + ); + } else { + console.log( + chalk.dim(" Chromium 실행 실패 시 Docker로 자동 전환됩니다."), + ); + } + console.log(""); + } +} + +// ─── GitHub Star 요청 ───────────────────────────────────────────── + +const STAR_FLAG_FILE = path.join(DATA_DIR, ".star-asked"); +const REPO = "kargnas/damn-my-slow-skt"; + +/** + * 첫 실행 시 한 번만 GitHub 스타 여부를 물어본다. + * gh CLI가 없거나 non-interactive면 조용히 스킵. + */ +async function askForStar(): Promise { + // interactive가 아니면 스킵 + if (!process.stdout.isTTY) return; + + // 이미 물어본 적 있으면 스킵 + if (fs.existsSync(STAR_FLAG_FILE)) return; + + // 플래그 먼저 기록 (다시 묻지 않기 위해) + try { + fs.mkdirSync(path.dirname(STAR_FLAG_FILE), { recursive: true }); + fs.writeFileSync(STAR_FLAG_FILE, new Date().toISOString(), "utf8"); + } catch { + return; + } + + // gh CLI 존재 확인 + try { + execSync("which gh 2>/dev/null", { stdio: "ignore" }); + } catch { + return; + } + + console.log(""); + const { star } = await inquirer.prompt([ + { + type: "confirm", + name: "star", + message: `도움이 됐다면 GitHub에 ⭐ 하나 남겨주시겠어요? (${REPO})`, + default: true, + }, + ]); + + if (star) { + try { + execSync(`gh repo edit ${REPO} --star 2>/dev/null`, { stdio: "ignore" }); + console.log(chalk.green("⭐ 감사합니다!")); + } catch { + // gh api로 fallback + try { + execSync(`gh api -X PUT /user/starred/${REPO} 2>/dev/null`, { + stdio: "ignore", + }); + console.log(chalk.green("⭐ 감사합니다!")); + } catch { + console.log(chalk.dim(`직접 스타해주세요: https://github.com/${REPO}`)); + } + } + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..845859e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,271 @@ +/** + * 설정 파일 로드/저장 - SKT/SK브로드밴드 전용 + * 기본 경로: ~/.damn-my-slow-isp/config-skt.yaml + */ + +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; +import os from 'os'; + +/** 모든 ISP 공용 데이터 디렉토리 (~/.damn-my-slow-isp/) */ +export const DATA_DIR = path.join(os.homedir(), '.damn-my-slow-isp'); + +export interface Credentials { + id: string; + password: string; +} + +export interface Plan { + speed_mbps: number; +} + +export interface Schedule { + time: string; + timezone: string; + /** 하루 최대 측정 횟수 (감면 성공 시 중단) */ + max_attempts: number; + /** 재시도 간격 (분). 첫 측정 후 이 간격으로 반복 */ + retry_interval_minutes: number; + /** 감면 신청 성공 시 나머지 시도 중단 */ + stop_on_complaint_success: boolean; +} + +export interface Notification { + discord_webhook: string; + telegram_bot_token: string; + telegram_chat_id: string; +} + +export interface TermsAgreement { + provider: 'skt'; + accepted: boolean; + accepted_at: string; + version: string; + urls: string[]; +} + +export interface Config { + _config_version: number; + credentials: Credentials; + terms: TermsAgreement; + /** 이의신청 시 연락처 */ + phone: string; + plan: Plan; + schedule: Schedule; + notification: Notification; + headless: boolean; + db_path: string; + auth_state_path: string; +} + +export const DEFAULT_CONFIG_PATH = path.join(DATA_DIR, 'config-skt.yaml'); +export const DEFAULT_AUTH_STATE_PATH = path.join(DATA_DIR, 'auth-skt.json'); +export const SKT_TERMS_VERSION = '2026-03-30'; +export const SKT_TERMS_URLS = [ + 'https://www.bworld.co.kr/footer/terms.do?menu_id=F01010000', + 'https://cdn.bworld.co.kr/home/fronta/data/download/stip/제31차%20전기통신서비스이용기본약관_260330.pdf', +]; + +function isValidIsoTimestamp(input: string): boolean { + if (!input.trim()) return false; + const date = new Date(input); + return !Number.isNaN(date.getTime()) && date.toISOString() === input; +} + +function expandHome(input: string): string { + if (input === '~') return os.homedir(); + if (input.startsWith('~/')) return path.join(os.homedir(), input.slice(2)); + return input; +} + +export function getDefaultConfig(): Config { + return { + _config_version: 4, + credentials: { id: '', password: '' }, + terms: { + provider: 'skt', + accepted: false, + accepted_at: '', + version: SKT_TERMS_VERSION, + urls: [...SKT_TERMS_URLS], + }, + phone: '', + plan: { speed_mbps: 1000 }, + schedule: { + time: '04:00', + timezone: 'Asia/Seoul', + max_attempts: 10, + retry_interval_minutes: 120, + stop_on_complaint_success: true, + }, + notification: { + discord_webhook: '', + telegram_bot_token: '', + telegram_chat_id: '', + }, + headless: true, + db_path: path.join(DATA_DIR, 'history-skt.db'), + auth_state_path: DEFAULT_AUTH_STATE_PATH, + }; +} + +export function loadConfig(configPath?: string): Config { + const cfgPath = configPath || DEFAULT_CONFIG_PATH; + + if (!fs.existsSync(cfgPath)) { + throw new Error( + `설정 파일이 없습니다: ${cfgPath}\n'npx -y damn-my-slow-skt@latest init' 명령으로 설정 파일을 생성하세요.` + ); + } + + const raw = yaml.load(fs.readFileSync(cfgPath, 'utf8')) as Record || {}; + const defaults = getDefaultConfig(); + + const creds = (raw.credentials || {}) as Record; + const terms = (raw.terms || {}) as Record; + const plan = (raw.plan || {}) as Record; + const sched = (raw.schedule || {}) as Partial; + const notif = (raw.notification || {}) as Record; + + return { + _config_version: Number(raw._config_version) || 1, + credentials: { + id: creds.id || '', + password: creds.password || '', + }, + terms: { + provider: 'skt', + accepted: terms.accepted === true, + accepted_at: typeof terms.accepted_at === 'string' ? terms.accepted_at : '', + version: typeof terms.version === 'string' ? terms.version : defaults.terms.version, + urls: Array.isArray(terms.urls) && terms.urls.every((url) => typeof url === 'string') + ? [...terms.urls] + : [...defaults.terms.urls], + }, + phone: String(raw.phone || ''), + plan: { + speed_mbps: Number(plan.speed_mbps || 1000), + }, + schedule: { + time: sched.time || '04:00', + timezone: sched.timezone || 'Asia/Seoul', + max_attempts: Number(sched.max_attempts) || 10, + retry_interval_minutes: Number(sched.retry_interval_minutes) || 120, + stop_on_complaint_success: + sched.stop_on_complaint_success !== undefined + ? Boolean(sched.stop_on_complaint_success) + : true, + }, + notification: { + discord_webhook: notif.discord_webhook || '', + telegram_bot_token: notif.telegram_bot_token || '', + telegram_chat_id: notif.telegram_chat_id || '', + }, + headless: raw.headless !== undefined ? Boolean(raw.headless) : true, + db_path: expandHome(String(raw.db_path || defaults.db_path)), + auth_state_path: expandHome(String(raw.auth_state_path || defaults.auth_state_path)), + }; +} + +export function saveConfig(config: Config, configPath?: string): void { + const cfgPath = configPath || DEFAULT_CONFIG_PATH; + + const data = { + _config_version: config._config_version, + credentials: { + id: config.credentials.id, + password: config.credentials.password, + }, + terms: { + provider: config.terms.provider, + accepted: config.terms.accepted, + accepted_at: config.terms.accepted_at, + version: config.terms.version, + urls: config.terms.urls, + }, + phone: config.phone, + plan: { + speed_mbps: config.plan.speed_mbps, + }, + schedule: { + time: config.schedule.time, + timezone: config.schedule.timezone, + max_attempts: config.schedule.max_attempts, + retry_interval_minutes: config.schedule.retry_interval_minutes, + stop_on_complaint_success: config.schedule.stop_on_complaint_success, + }, + notification: { + discord_webhook: config.notification.discord_webhook, + telegram_bot_token: config.notification.telegram_bot_token, + telegram_chat_id: config.notification.telegram_chat_id, + }, + headless: config.headless, + db_path: config.db_path, + auth_state_path: config.auth_state_path, + }; + + fs.writeFileSync(cfgPath, yaml.dump(data), 'utf8'); +} + +/** + * run 명령 실행에 필요한 필수 설정이 모두 채워져 있는지 검증. + * 누락된 항목이 있으면 필드명 배열을 반환, 모두 있으면 빈 배열. + */ +export function validateRequiredFields(config: Config): string[] { + const missing: string[] = []; + if (!config.credentials.id) missing.push('credentials.id (SKT/B world 아이디)'); + if (!config.credentials.password) missing.push('credentials.password (SKT/B world 비밀번호)'); + if ( + config.terms.provider !== 'skt' || + config.terms.accepted !== true || + !isValidIsoTimestamp(config.terms.accepted_at) || + config.terms.version !== SKT_TERMS_VERSION + ) { + missing.push('terms (SKT/SK브로드밴드 공식 이용약관 동의)'); + } + if (!config.phone) missing.push('phone (연락처)'); + return missing; +} + +export function getExampleConfigContent(): string { + return `# damn-my-slow-skt 설정 파일 +# 주의: 이 파일은 .gitignore에 포함되어 있습니다 (비밀번호 보호) + +_config_version: 4 + +credentials: + id: "skt아이디@example.com" + password: "비밀번호" + +terms: + provider: "skt" + accepted: true + accepted_at: "2026-03-30T00:00:00.000Z" + version: "${SKT_TERMS_VERSION}" + urls: + - "${SKT_TERMS_URLS[0]}" + - "${SKT_TERMS_URLS[1]}" + +phone: "01012345678" + +plan: + speed_mbps: 1000 # 계약 속도 (Mbps) - 기가라이트: 1000, 기가 인터넷: 2000 + +schedule: + time: "04:00" # 첫 측정 시작 시간 + timezone: "Asia/Seoul" + max_attempts: 10 # 하루 최대 측정 횟수 (감면 성공 시 중단) + retry_interval_minutes: 120 # 재시도 간격 (분) - 기본 2시간 + stop_on_complaint_success: true # 감면 성공 시 나머지 시도 중단 + +notification: + discord_webhook: "" # Discord 웹훅 URL (선택) + telegram_bot_token: "" # Telegram 봇 토큰 (선택) + telegram_chat_id: "" # Telegram 채팅 ID (선택) + +headless: true # false로 설정하면 브라우저 창 표시 (디버그용) +db_path: "~/.damn-my-slow-isp/history-skt.db" # 측정 이력 저장 경로 +auth_state_path: "~/.damn-my-slow-isp/auth-skt.json" # B world 로그인 세션 저장 경로 +`; +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..a6a069c --- /dev/null +++ b/src/db.ts @@ -0,0 +1,257 @@ +/** + * SQLite 측정 이력 저장/조회 + * Uses Node.js built-in sqlite (node:sqlite) available since Node 22+ + * Falls back to JSON file storage for older Node versions + */ + +import fs from 'fs'; +import path from 'path'; + +export interface SpeedRecord { + id?: number; + isp: string; + measured_at: string; + download_mbps: number; + upload_mbps: number; + ping_ms: number; + sla_result: 'pass' | 'fail' | 'unknown'; + complaint_filed: boolean; + complaint_result: 'success' | 'failed' | 'skipped' | 'not_applicable'; + raw_data: string; + error: string; +} + +export interface Stats { + total: number; + sla_pass: number; + sla_fail: number; + complaints_filed: number; + avg_download_mbps: number; + avg_upload_mbps: number; + avg_ping_ms: number; +} + +interface SqliteDb { + exec(sql: string): void; + prepare(sql: string): SqliteStatement; + close(): void; +} + +interface SqliteStatement { + run(...params: unknown[]): { lastInsertRowid: number }; + all(...params: unknown[]): Record[]; +} + +function openSqlite(dbPath: string): SqliteDb | null { + try { + // Node 22.5+ built-in sqlite + const { DatabaseSync } = require('node:sqlite') as { + DatabaseSync: new (path: string) => SqliteDb; + }; + return new DatabaseSync(dbPath); + } catch { + return null; + } +} + +// ─── JSON fallback store ─────────────────────────────────────────────────── + +interface JsonStore { + records: SpeedRecord[]; + nextId: number; +} + +function readJsonStore(storePath: string): JsonStore { + try { + const raw = fs.readFileSync(storePath, 'utf8'); + return JSON.parse(raw) as JsonStore; + } catch { + return { records: [], nextId: 1 }; + } +} + +function writeJsonStore(storePath: string, store: JsonStore): void { + fs.writeFileSync(storePath, JSON.stringify(store, null, 2), 'utf8'); +} + +// ─── SpeedDatabase ──────────────────────────────────────────────────────── + +export class SpeedDatabase { + private dbPath: string; + private db: SqliteDb | null; + private jsonPath: string; + private usingJson: boolean; + + constructor(dbPath: string) { + // Expand ~ in path + if (dbPath.startsWith('~/') || dbPath === '~') { + const home = process.env.HOME || process.env.USERPROFILE || ''; + dbPath = path.join(home, dbPath.slice(2)); + } + + this.dbPath = dbPath; + this.jsonPath = dbPath.replace(/\.db$/, '.json'); + + // Ensure directory exists + const dir = path.dirname(dbPath); + if (dir && dir !== '.' && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + this.db = openSqlite(dbPath); + this.usingJson = this.db === null; + + if (!this.usingJson) { + this.initDb(); + console.log(`DB: SQLite (${dbPath})`); + } else { + console.log(`DB: JSON fallback (${this.jsonPath})`); + } + } + + private initDb(): void { + this.db!.exec(` + CREATE TABLE IF NOT EXISTS speed_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + isp TEXT NOT NULL, + measured_at TEXT NOT NULL, + download_mbps REAL DEFAULT 0, + upload_mbps REAL DEFAULT 0, + ping_ms REAL DEFAULT 0, + sla_result TEXT DEFAULT 'unknown', + complaint_filed INTEGER DEFAULT 0, + complaint_result TEXT DEFAULT 'skipped', + raw_data TEXT DEFAULT '{}', + error TEXT DEFAULT '' + ) + `); + } + + save(record: Omit): number { + if (!this.usingJson && this.db) { + const stmt = this.db.prepare(` + INSERT INTO speed_records + (isp, measured_at, download_mbps, upload_mbps, ping_ms, + sla_result, complaint_filed, complaint_result, raw_data, error) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const info = stmt.run( + record.isp, + record.measured_at, + record.download_mbps, + record.upload_mbps, + record.ping_ms, + record.sla_result, + record.complaint_filed ? 1 : 0, + record.complaint_result, + record.raw_data, + record.error + ); + + return info.lastInsertRowid; + } else { + // JSON fallback + const store = readJsonStore(this.jsonPath); + const id = store.nextId++; + store.records.push({ ...record, id }); + writeJsonStore(this.jsonPath, store); + return id; + } + } + + getHistory(limit = 50, month?: string): SpeedRecord[] { + if (!this.usingJson && this.db) { + let query = 'SELECT * FROM speed_records'; + const params: unknown[] = []; + + if (month) { + query += ' WHERE measured_at LIKE ?'; + params.push(`${month}%`); + } + + query += ' ORDER BY measured_at DESC LIMIT ?'; + params.push(limit); + + const rows = this.db.prepare(query).all(...params); + + return rows.map((row) => ({ + id: row.id as number, + isp: row.isp as string, + measured_at: row.measured_at as string, + download_mbps: (row.download_mbps as number) || 0, + upload_mbps: (row.upload_mbps as number) || 0, + ping_ms: (row.ping_ms as number) || 0, + sla_result: (row.sla_result as 'pass' | 'fail' | 'unknown') || 'unknown', + complaint_filed: Boolean(row.complaint_filed), + complaint_result: + (row.complaint_result as SpeedRecord['complaint_result']) || 'skipped', + raw_data: (row.raw_data as string) || '{}', + error: (row.error as string) || '', + })); + } else { + // JSON fallback + const store = readJsonStore(this.jsonPath); + let records = [...store.records]; + + if (month) { + records = records.filter((r) => r.measured_at.startsWith(month)); + } + + records.sort((a, b) => b.measured_at.localeCompare(a.measured_at)); + return records.slice(0, limit); + } + } + + /** 오늘(KST 기준) 측정 기록 조회 */ + getTodayRecords(timezone = 'Asia/Seoul'): SpeedRecord[] { + // measured_at은 UTC ISO 문자열(`...Z`)로 저장되므로, startsWith로 비교하면 + // KST 00:00–08:59(UTC 전날) 시간대의 기록이 누락된다. + // 각 레코드의 UTC 시각을 타임존 로컬 날짜로 변환해서 비교한다. + const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: timezone }); // YYYY-MM-DD + return this.getHistory(9999).filter((r) => { + const recordDate = new Date(r.measured_at); + if (isNaN(recordDate.getTime())) return false; + const localDate = recordDate.toLocaleDateString('sv-SE', { timeZone: timezone }); + return localDate === todayStr; + }); + } + + /** 오늘 감면 신청 성공 여부 */ + hasTodayComplaintSuccess(timezone = 'Asia/Seoul'): boolean { + return this.getTodayRecords(timezone).some((r) => r.complaint_result === 'success'); + } + + getStats(month?: string): Stats { + const records = this.getHistory(9999, month); + + if (records.length === 0) { + return { + total: 0, + sla_pass: 0, + sla_fail: 0, + complaints_filed: 0, + avg_download_mbps: 0, + avg_upload_mbps: 0, + avg_ping_ms: 0, + }; + } + + return { + total: records.length, + sla_pass: records.filter((r) => r.sla_result === 'pass').length, + sla_fail: records.filter((r) => r.sla_result === 'fail').length, + complaints_filed: records.filter((r) => r.complaint_filed).length, + avg_download_mbps: + records.reduce((s, r) => s + r.download_mbps, 0) / records.length, + avg_upload_mbps: + records.reduce((s, r) => s + r.upload_mbps, 0) / records.length, + avg_ping_ms: records.reduce((s, r) => s + r.ping_ms, 0) / records.length, + }; + } + + close(): void { + this.db?.close(); + } +} diff --git a/src/docker.ts b/src/docker.ts new file mode 100644 index 0000000..3a3acc0 --- /dev/null +++ b/src/docker.ts @@ -0,0 +1,146 @@ +/** + * Docker 자동 감지 및 래핑 + * + * Synology NAS 등 GTK 라이브러리가 없는 Linux에서 + * Chromium 실행 실패 시 Playwright 공식 Docker 이미지로 자동 전환. + */ + +import { execSync, spawnSync } from 'child_process'; +import fs from 'fs'; +import chalk from 'chalk'; +import { DATA_DIR } from './config'; + +// ─── 감지 함수 ───────────────────────────────────────────────────── + +/** Docker 컨테이너 내부에서 실행 중인지 확인 */ +export function isInsideDocker(): boolean { + // 우리가 re-exec 시 설정하는 환경변수 (가장 확실) + if (process.env.DAMN_DOCKER === '1') return true; + + // Docker 컨테이너는 /.dockerenv 파일이 존재 + if (fs.existsSync('/.dockerenv')) return true; + + // cgroup 기반 감지 (일부 Linux 환경) + try { + const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf-8'); + if (cgroup.includes('docker') || cgroup.includes('containerd')) return true; + } catch { + // /proc가 없는 환경 — Docker 아님 + } + + return false; +} + +/** Docker CLI가 설치되어 있고 Docker 데몬이 실행 중인지 확인 */ +export function isDockerAvailable(): boolean { + try { + execSync('docker info', { stdio: 'pipe', timeout: 15_000 }); + return true; + } catch { + return false; + } +} + +/** + * Chromium 실행 실패가 shared library 누락 때문인지 판별. + * Synology 등에서 libatk-1.0.so.0, libgdk-3.so.0 등이 없을 때 발생. + */ +export function isSharedLibraryError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + return ( + msg.includes('shared libraries') || + msg.includes('cannot open shared object file') || + msg.includes('libatk') || + msg.includes('libgdk') || + msg.includes('libgtk') || + msg.includes('libgobject') || + msg.includes('libglib') || + msg.includes('libpango') || + msg.includes('libnss') || + msg.includes('libnspr') + ); +} + +// ─── Docker 재실행 ───────────────────────────────────────────────── + +/** + * 현재 Playwright 버전에 맞는 Docker 이미지 태그 반환. + * Playwright 공식 Docker 이미지: mcr.microsoft.com/playwright:v{version}-noble + */ +function getDockerImageTag(): string { + try { + const pkgPath = require.resolve('playwright/package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + return `v${pkg.version}-noble`; + } catch { + // require.resolve 실패 시 (npx 환경 등) — 안전한 기본값 + return 'v1.52.0-noble'; + } +} + +/** + * 현재 CLI 명령을 Docker 컨테이너 안에서 재실행. + * + * - DATA_DIR (~/.damn-my-slow-isp)을 컨테이너에 마운트하여 설정/DB 공유 + * - process.argv에서 서브커맨드와 옵션을 추출하여 그대로 전달 + * - DAMN_DOCKER=1 환경변수로 재귀 방지 + * + * 이 함수는 반환하지 않음 (process.exit 호출) + */ +export function reExecInDocker(): never { + const imageTag = getDockerImageTag(); + const dockerImage = `mcr.microsoft.com/playwright:${imageTag}`; + const containerDataDir = '/root/.damn-my-slow-isp'; + + // process.argv에서 서브커맨드 + 옵션 추출 (node, script 경로 제외) + // 예: ['node', 'damn-my-slow-skt', 'run', '--dry-run'] → ['run', '--dry-run'] + const cliArgs = process.argv.slice(2); + + // DATA_DIR 경로를 컨테이너 경로로 재매핑 + // (사용자가 -c 옵션으로 DATA_DIR 내 경로를 지정한 경우) + const remappedArgs = cliArgs.map(arg => { + if (arg.includes(DATA_DIR)) { + return arg.replace(DATA_DIR, containerDataDir); + } + return arg; + }); + + console.log(chalk.cyan('\n🐳 Linux에서 Chromium 실행 불가 — Docker로 자동 전환')); + console.log(chalk.dim(` 이미지: ${dockerImage}`)); + console.log(chalk.dim(` 마운트: ${DATA_DIR} → ${containerDataDir}`)); + console.log(''); + + const dockerArgs = [ + 'run', '--rm', + '-v', `${DATA_DIR}:${containerDataDir}:rw`, + '-e', 'DAMN_DOCKER=1', + // 호스트에 TTY가 있으면 -it 추가 (interactive 프롬프트 + 색상 출력) + ...(process.stdout.isTTY ? ['-it'] : []), + dockerImage, + 'npx', '-y', 'damn-my-slow-skt@latest', + ...remappedArgs, + ]; + + const result = spawnSync('docker', dockerArgs, { stdio: 'inherit' }); + process.exit(result.status ?? 1); +} + +/** + * Docker 미설치 시 안내 메시지 출력. + * Synology 사용자를 위한 구체적인 설치 가이드 포함. + */ +export function printDockerInstallGuide(errorMsg: string): void { + console.error(chalk.red('\n❌ Chromium 실행에 필요한 시스템 라이브러리가 없습니다.')); + console.error(chalk.dim(` ${errorMsg}`)); + console.error(''); + console.error(chalk.yellow('💡 Docker를 설치하면 자동으로 해결됩니다:')); + console.error(chalk.dim(' Synology: 패키지센터 → Container Manager 설치')); + console.error(chalk.dim(' 일반 Linux: https://docs.docker.com/engine/install/')); + console.error(''); + console.error(chalk.dim(' Docker 설치 후 다시 실행하면 자동으로 Docker를 사용합니다.')); + console.error(''); + console.error(chalk.dim(' 수동 실행:')); + console.error(chalk.dim(` docker run --rm -v ${DATA_DIR}:/root/.damn-my-slow-isp \\`)); + console.error(chalk.dim(' mcr.microsoft.com/playwright:v1.52.0-noble \\')); + console.error(chalk.dim(' npx -y damn-my-slow-skt@latest run')); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..15666f7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +/** + * damn-my-slow-skt - CLI 엔트리포인트 + */ + +import { buildCli } from './cli'; + +const program = buildCli(); +program.parseAsync(process.argv).catch((err: unknown) => { + const error = err instanceof Error ? err : new Error(String(err)); + console.error(error.message); + process.exit(1); +}); diff --git a/src/migration.ts b/src/migration.ts new file mode 100644 index 0000000..abdc13f --- /dev/null +++ b/src/migration.ts @@ -0,0 +1,194 @@ +/** + * 업데이트 후 마이그레이션 체크 시스템 + * + * 설정 파일의 _config_version을 기준으로 필요한 마이그레이션을 감지하고 + * 사용자에게 적용 여부를 묻는다. + */ + +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { Config, saveConfig, SKT_TERMS_URLS, SKT_TERMS_VERSION } from './config'; +import { installSchedule, removeSchedule, getPlatform } from './scheduler'; + +/** 현재 최신 config version */ +export const CURRENT_CONFIG_VERSION = 4; + +interface Migration { + /** 이 마이그레이션이 적용되는 target version */ + version: number; + /** 사용자에게 보여줄 제목 */ + title: string; + /** 상세 설명 */ + description: string; + /** 마이그레이션 적용 함수. true 반환 시 config가 변경됨 */ + apply: (config: Config) => Config; + /** 스케줄 재등록이 필요한지 여부 */ + requiresScheduleReinstall: boolean; +} + +/** + * 버전별 마이그레이션 목록. + * version 오름차순으로 정의. 사용자의 현재 _config_version보다 높은 것만 실행. + */ +const MIGRATIONS: Migration[] = [ + { + version: 2, + title: '설정 파일 경로 변경', + description: '기본 경로가 ./config.yaml → ~/.damn-my-slow-isp/config-skt.yaml로 변경되었습니다.', + apply: (config) => { + // 경로 변경은 이미 코드에서 처리됨. config_version만 업데이트. + return { ...config, _config_version: 2 }; + }, + requiresScheduleReinstall: false, + }, + { + version: 3, + title: '다회 측정 지원 (하루 최대 10회)', + description: [ + '기존: 하루 1회 측정', + '변경: 하루 최대 10회, 2시간 간격으로 측정 (감면 성공 시 중단)', + '', + '속도가 정상으로 나올 수 있는 시간대를 피해 여러 번 측정합니다.', + '스케줄 재등록이 필요합니다.', + ].join('\n'), + apply: (config) => { + return { + ...config, + schedule: { + ...config.schedule, + max_attempts: 10, + retry_interval_minutes: 120, + stop_on_complaint_success: true, + }, + _config_version: 3, + }; + }, + requiresScheduleReinstall: true, + }, + { + version: 4, + title: 'SKT/SK브로드밴드 공식 이용약관 동의 기록', + description: [ + 'SKT/SK브로드밴드 SLA 측정과 감면 신청을 실행하려면 공식 이용약관 동의 기록이 필요합니다.', + '아래 공식 약관을 확인한 뒤 적용하면 현재 버전 동의 시각이 설정 파일에 저장됩니다.', + '', + `SK브로드밴드 이용약관: ${SKT_TERMS_URLS[0]}`, + `전기통신 서비스 이용약관 PDF: ${SKT_TERMS_URLS[1]}`, + ].join('\n'), + apply: (config) => { + return { + ...config, + terms: { + provider: 'skt', + accepted: true, + accepted_at: new Date().toISOString(), + version: SKT_TERMS_VERSION, + urls: [...SKT_TERMS_URLS], + }, + _config_version: 4, + }; + }, + requiresScheduleReinstall: false, + }, +]; + +/** + * 대기 중인 마이그레이션이 있는지 확인하고 interactive하게 적용. + * non-interactive (cron/launchd) 환경에서는 스킵하고 안내만 출력. + */ +export async function checkAndRunMigrations( + config: Config, + configPath: string, + options: { interactive?: boolean } = {} +): Promise { + const currentVersion = config._config_version || 1; + + if (currentVersion >= CURRENT_CONFIG_VERSION) { + return config; // 최신 상태 + } + + const pending = MIGRATIONS.filter((m) => m.version > currentVersion); + if (pending.length === 0) return config; + + console.log(''); + console.log(chalk.yellow('📋 업데이트 후 변경 사항이 있습니다:')); + console.log(''); + + for (const migration of pending) { + console.log(chalk.bold(` [v${migration.version}] ${migration.title}`)); + for (const line of migration.description.split('\n')) { + console.log(chalk.dim(` ${line}`)); + } + console.log(''); + } + + // non-interactive (cron/launchd 등)에서는 적용하지 않고 안내만 + const isInteractive = options.interactive !== undefined + ? options.interactive + : process.stdout.isTTY === true; + + if (!isInteractive) { + console.log(chalk.yellow(' → 대화형 환경에서 "npx -y damn-my-slow-skt@latest run" 을 실행하여 마이그레이션을 적용하세요.')); + console.log(''); + return config; + } + + let updatedConfig = { ...config }; + let needScheduleReinstall = false; + + for (const migration of pending) { + const { apply: doApply } = await inquirer.prompt([ + { + type: 'confirm', + name: 'apply', + message: `[v${migration.version}] ${migration.title} - 적용하시겠습니까?`, + default: true, + }, + ]); + + if (doApply) { + updatedConfig = migration.apply(updatedConfig); + if (migration.requiresScheduleReinstall) { + needScheduleReinstall = true; + } + console.log(chalk.green(` ✅ v${migration.version} 적용 완료`)); + } else { + // 거절해도 version은 올려서 다시 묻지 않도록 + updatedConfig._config_version = migration.version; + console.log(chalk.dim(` ⏭️ v${migration.version} 건너뜀`)); + } + } + + // config 저장 + saveConfig(updatedConfig, configPath); + console.log(chalk.dim(`\n 설정 파일 저장됨: ${configPath}`)); + + // 스케줄 재등록 + if (needScheduleReinstall) { + const platform = getPlatform(); + if (platform !== 'windows' && platform !== 'unknown') { + const { reinstall } = await inquirer.prompt([ + { + type: 'confirm', + name: 'reinstall', + message: '스케줄을 새 설정으로 재등록하시겠습니까?', + default: true, + }, + ]); + + if (reinstall) { + try { + removeSchedule(); + installSchedule(updatedConfig, configPath); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(chalk.red(`스케줄 재등록 실패: ${err.message}`)); + console.error(chalk.dim(`수동으로 재등록하세요: npx -y damn-my-slow-skt@latest schedule install --config ${configPath}`)); + } + } + } + } + + console.log(''); + return updatedConfig; +} diff --git a/src/notify.ts b/src/notify.ts new file mode 100644 index 0000000..167afea --- /dev/null +++ b/src/notify.ts @@ -0,0 +1,96 @@ +/** + * Discord / Telegram 알림 + */ + +import axios from 'axios'; +import { Config } from './config'; +import { SpeedRecord } from './db'; + +export function formatRecord(record: SpeedRecord): string { + // 자동화 오류로 측정 자체가 실패한 경우 — 속도값이 0이어도 의미 없음 + if (record.error) { + return ( + `**인터넷 속도 측정 실패** (${record.measured_at.slice(0, 16)})\n` + + `🚨 오류: ${record.error}` + ); + } + + const slaEmoji = + record.sla_result === 'pass' ? '✅' : record.sla_result === 'fail' ? '❌' : '⚠️'; + let complaintInfo = ''; + + if (record.complaint_filed) { + complaintInfo = `\n🔔 이의신청: ${record.complaint_result === 'success' ? '완료' : '실패'}`; + } + + return ( + `**인터넷 속도 측정 결과** (${record.measured_at.slice(0, 16)})\n` + + `${slaEmoji} SLA: ${record.sla_result.toUpperCase()}\n` + + `⬇️ 다운로드: ${record.download_mbps.toFixed(1)} Mbps` + + complaintInfo + ); +} + +export async function notifyDiscord(webhookUrl: string, record: SpeedRecord): Promise { + if (!webhookUrl) return false; + + const message = formatRecord(record); + const color = record.error ? 0xff8800 : record.sla_result === 'pass' ? 0x00ff00 : 0xff0000; + + const payload = { + embeds: [ + { + title: '🐌 damn-my-slow-skt', + description: message, + color, + }, + ], + }; + + try { + await axios.post(webhookUrl, payload, { timeout: 10000 }); + console.log('Discord 알림 전송 완료'); + return true; + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(`Discord 알림 실패: ${err.message}`); + return false; + } +} + +export async function notifyTelegram( + botToken: string, + chatId: string, + record: SpeedRecord +): Promise { + if (!botToken || !chatId) return false; + + const message = formatRecord(record).replace(/\*\*/g, '*'); + const url = `https://api.telegram.org/bot${botToken}/sendMessage`; + + try { + await axios.post( + url, + { chat_id: chatId, text: message, parse_mode: 'Markdown' }, + { timeout: 10000 } + ); + console.log('Telegram 알림 전송 완료'); + return true; + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(`Telegram 알림 실패: ${err.message}`); + return false; + } +} + +export async function sendNotifications(config: Config, record: SpeedRecord): Promise { + const { notification: notif } = config; + + if (notif.discord_webhook) { + await notifyDiscord(notif.discord_webhook, record); + } + + if (notif.telegram_bot_token && notif.telegram_chat_id) { + await notifyTelegram(notif.telegram_bot_token, notif.telegram_chat_id, record); + } +} diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..211c279 --- /dev/null +++ b/src/report.ts @@ -0,0 +1,72 @@ +/** + * 측정 이력 리포트 생성 + */ + +import Table from 'cli-table3'; +import chalk from 'chalk'; +import { SpeedRecord, Stats } from './db'; + +export function printHistory(records: SpeedRecord[]): void { + if (records.length === 0) { + console.log(chalk.yellow('측정 이력이 없습니다.')); + return; + } + + const table = new Table({ + head: [ + chalk.cyan('일시'), + chalk.cyan('ISP'), + chalk.cyan('다운로드'), + chalk.cyan('업로드'), + chalk.cyan('Ping'), + chalk.cyan('SLA'), + chalk.cyan('이의신청'), + ], + colWidths: [20, 6, 14, 14, 10, 10, 10], + style: { 'padding-left': 1, 'padding-right': 1 }, + }); + + for (const r of records) { + const slaIcon = + r.sla_result === 'pass' ? chalk.green('✅') : r.sla_result === 'fail' ? chalk.red('❌') : '⚠️'; + const complaintIcon = + r.complaint_result === 'success' + ? chalk.green('✅') + : r.complaint_result === 'failed' + ? chalk.red('❌') + : '-'; + + table.push([ + r.measured_at.slice(0, 16), + r.isp.toUpperCase(), + `${r.download_mbps.toFixed(1)} Mbps`, + `${r.upload_mbps.toFixed(1)} Mbps`, + `${r.ping_ms.toFixed(0)} ms`, + slaIcon, + complaintIcon, + ]); + } + + console.log('\n📊 인터넷 속도 측정 이력'); + console.log(table.toString()); +} + +export function printStats(stats: Stats): void { + if (stats.total === 0) { + console.log(chalk.yellow('측정 데이터가 없습니다.')); + return; + } + + console.log('\n' + chalk.bold('📈 요약 리포트')); + console.log(` 전체 측정: ${stats.total}회`); + console.log(` SLA 통과: ${chalk.green(`${stats.sla_pass}회`)}`); + console.log(` SLA 미달: ${chalk.red(`${stats.sla_fail}회`)}`); + console.log(` 이의신청: ${stats.complaints_filed}회`); + console.log(` 평균 다운로드: ${stats.avg_download_mbps.toFixed(1)} Mbps`); + console.log(` 평균 업로드: ${stats.avg_upload_mbps.toFixed(1)} Mbps`); + + if (stats.sla_fail > 0) { + const failRate = ((stats.sla_fail / stats.total) * 100).toFixed(1); + console.log(`\n ${chalk.bold.red(`⚠️ SLA 미달률: ${failRate}%`)}`); + } +} diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 0000000..fd3c83f --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,556 @@ +/** + * 자동 스케줄 설치/제거 (macOS launchd / Linux systemd/cron) + */ + +import os from 'os'; +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; +import { Config, DATA_DIR, DEFAULT_CONFIG_PATH } from './config'; + +/** POSIX shell 안전 문자열 이스케이프. 공백·특수문자가 포함된 경로를 cron/systemd에 안전하게 넘긴다. */ +function shellQuote(s: string): string { + if (/^[a-zA-Z0-9_./:@=,-]+$/.test(s)) return s; + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +const LAUNCHD_PLIST_PATH = path.join( + os.homedir(), + 'Library', + 'LaunchAgents', + 'com.damn-my-slow-skt.plist' +); +const CRON_COMMENT = '# damn-my-slow-skt'; +const SYSTEMD_SERVICE_PATH = path.join( + os.homedir(), + '.config', + 'systemd', + 'user', + 'damn-my-slow-skt.service' +); +const SYSTEMD_TIMER_PATH = path.join( + os.homedir(), + '.config', + 'systemd', + 'user', + 'damn-my-slow-skt.timer' +); + +export function getPlatform(): 'macos' | 'linux' | 'windows' | 'unknown' { + const platform = os.platform(); + if (platform === 'darwin') return 'macos'; + if (platform === 'linux') return 'linux'; + if (platform === 'win32') return 'windows'; + return 'unknown'; +} + +/** + * npx 임시 캐시 경로인지 판별. + * npx는 _npx/ 디렉토리 아래에 임시 설치하므로, launchd/cron에 이 경로를 기록하면 + * 세션 종료 후 파일이 사라져 실행 실패한다. + */ +function isNpxTempPath(p: string): boolean { + return p.includes('/_npx/') || p.includes('\\_npx\\'); +} + +interface CliExec { + /** 실행 바이너리 (npx 모드면 npx 절대경로, 아니면 CLI 절대경로) */ + program: string; + /** program 뒤에 붙는 인자 (npx 모드면 ['--yes', 'damn-my-slow-skt']) */ + prefixArgs: string[]; + /** npx 모드 여부 */ + isNpx: boolean; +} + +function getCliExec(): CliExec { + // 1) 글로벌 설치 경로 확인 + try { + const globalPath = execSync('which damn-my-slow-skt 2>/dev/null', { encoding: 'utf8' }).trim(); + if (globalPath && !isNpxTempPath(globalPath)) { + return { program: globalPath, prefixArgs: [], isNpx: false }; + } + } catch { + // ignore + } + + // 2) process.argv[1]이 안정적 경로(글로벌 or 로컬 node_modules)인 경우 + const scriptPath = process.argv[1]; + if (scriptPath && scriptPath.includes('damn-my-slow-skt') && !isNpxTempPath(scriptPath)) { + return { program: scriptPath, prefixArgs: [], isNpx: false }; + } + + // 3) npx 모드 - npx 바이너리 절대경로를 찾아서 사용 + let npxPath = 'npx'; + try { + npxPath = execSync('which npx 2>/dev/null', { encoding: 'utf8' }).trim() || 'npx'; + } catch { + // fallback + } + return { program: npxPath, prefixArgs: ['--yes', 'damn-my-slow-skt'], isNpx: true }; +} + +/** 사용자 안내용 실행 명령어 문자열 */ +export function getRunCommand(): string { + const exec = getCliExec(); + if (exec.isNpx) { + return 'npx damn-my-slow-skt'; + } + return 'damn-my-slow-skt'; +} + +// ───────────────────────────────────────────── +// 스케줄 시간 계산 +// ───────────────────────────────────────────── + +interface ScheduleTime { hour: number; minute: number; } + +/** + * 시작 시간 + 간격 + 최대 횟수로 트리거 시간 목록 생성. + * 예: 04:00, max=10, interval=120 → 04:00, 06:00, 08:00, ..., 22:00 + */ +function buildScheduleTimes(config: Config): ScheduleTime[] { + const [startH, startM] = config.schedule.time.split(':').map(Number); + const maxAttempts = config.schedule.max_attempts || 10; + const intervalMin = config.schedule.retry_interval_minutes || 120; + + const times: ScheduleTime[] = []; + for (let i = 0; i < maxAttempts; i++) { + const totalMinutes = (startH * 60 + startM) + (i * intervalMin); + const hour = Math.floor(totalMinutes / 60) % 24; + const minute = totalMinutes % 60; + + // 다음 날로 넘어가면 중단 (24시간 내만) + if (i > 0 && totalMinutes >= 24 * 60) break; + + times.push({ hour, minute }); + } + return times; +} + +/** 스케줄 시간을 보기 좋게 출력 */ +function formatScheduleTimes(times: ScheduleTime[]): string { + return times.map((t) => `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}`).join(', '); +} + +// ───────────────────────────────────────────── +// macOS - launchd plist +// ───────────────────────────────────────────── + +function buildLaunchdPlist(config: Config, configPath: string): string { + const times = buildScheduleTimes(config); + const exec = getCliExec(); + const logDir = DATA_DIR; + const logPath = path.join(logDir, 'run.log'); + const errPath = path.join(logDir, 'run.error.log'); + + fs.mkdirSync(logDir, { recursive: true }); + + const args = [exec.program, ...exec.prefixArgs, 'run', '--config', configPath]; + const argsXml = args.map((a) => ` ${a}`).join('\n'); + + // launchd는 StartCalendarInterval을 array로 받으면 여러 시간에 트리거 + const calendarEntries = times.map((t) => ` + Hour + ${t.hour} + Minute + ${t.minute} + `).join('\n'); + + return ` + + + + Label + com.damn-my-slow-skt + ProgramArguments + +${argsXml} + + StartCalendarInterval + +${calendarEntries} + + StandardOutPath + ${logPath} + StandardErrorPath + ${errPath} + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin + + RunAtLoad + + + +`; +} + +export function installMacos(config: Config, configPath: string): void { + const plistDir = path.dirname(LAUNCHD_PLIST_PATH); + fs.mkdirSync(plistDir, { recursive: true }); + + // 기존 언로드 + try { + execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); + } catch { + // ignore + } + + const plist = buildLaunchdPlist(config, configPath); + fs.writeFileSync(LAUNCHD_PLIST_PATH, plist, 'utf8'); + + execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`); + + const times = buildScheduleTimes(config); + console.log(`✅ macOS launchd 스케줄 등록 완료: ${LAUNCHD_PLIST_PATH}`); + console.log(` 매일 ${times.length}회 실행: ${formatScheduleTimes(times)}`); + console.log(` 감면 성공 시 나머지 실행은 자동 스킵됩니다.`); + console.log(`\n 제거하려면: npx -y damn-my-slow-skt@latest schedule remove`); +} + +export function removeMacos(): void { + if (!fs.existsSync(LAUNCHD_PLIST_PATH)) { + console.log('등록된 launchd 스케줄이 없습니다.'); + return; + } + + try { + execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); + } catch { + // ignore + } + + fs.unlinkSync(LAUNCHD_PLIST_PATH); + console.log('✅ macOS launchd 스케줄 제거 완료'); +} + +// ───────────────────────────────────────────── +// Linux - systemd timer 또는 cron +// ───────────────────────────────────────────── + +function hasSystemd(): boolean { + try { + execSync('systemctl --user status 2>/dev/null', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** crontab 바이너리가 PATH에 존재하는지 확인 */ +function hasCrontab(): boolean { + try { + execSync('which crontab 2>/dev/null', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Synology NAS 감지 — /etc/synoinfo.conf 존재 여부로 판별. + * Synology DSM은 user-level crontab이 없고 /etc/crontab을 직접 편집해야 한다. + */ +function isSynology(): boolean { + return fs.existsSync('/etc/synoinfo.conf'); +} + +export function installLinux(config: Config, configPath: string): void { + if (hasSystemd()) { + installSystemd(config, configPath); + } else if (isSynology()) { + installSynologyCron(config, configPath); + } else if (hasCrontab()) { + installCron(config, configPath); + } else { + throw new Error( + 'crontab 명령어를 찾을 수 없습니다.\n' + + '수동으로 cron을 설정하세요:\n' + + ` npx --yes damn-my-slow-skt run --config ${configPath}` + ); + } +} + +function installSystemd(config: Config, configPath: string): void { + const times = buildScheduleTimes(config); + const exec = getCliExec(); + + const serviceDir = path.dirname(SYSTEMD_SERVICE_PATH); + fs.mkdirSync(serviceDir, { recursive: true }); + + const execCmd = [shellQuote(exec.program), ...exec.prefixArgs.map(shellQuote), 'run', '--config', shellQuote(configPath)].join(' '); + + const serviceContent = `[Unit] +Description=damn-my-slow-skt SKT/SK Broadband SLA Speed Test + +[Service] +Type=oneshot +ExecStart=${execCmd} +StandardOutput=journal +StandardError=journal +`; + + // systemd는 여러 OnCalendar 라인을 지원 + const onCalendarLines = times + .map((t) => `OnCalendar=*-*-* ${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}:00`) + .join('\n'); + + const timerContent = `[Unit] +Description=damn-my-slow-skt daily timer (${times.length}회/일) + +[Timer] +${onCalendarLines} +Persistent=true + +[Install] +WantedBy=timers.target +`; + + fs.writeFileSync(SYSTEMD_SERVICE_PATH, serviceContent, 'utf8'); + fs.writeFileSync(SYSTEMD_TIMER_PATH, timerContent, 'utf8'); + + execSync('systemctl --user daemon-reload'); + execSync('systemctl --user enable damn-my-slow-skt.timer'); + execSync('systemctl --user start damn-my-slow-skt.timer'); + + console.log(`✅ systemd 타이머 등록 완료`); + console.log(` 매일 ${times.length}회 실행: ${formatScheduleTimes(times)}`); + console.log(` 감면 성공 시 나머지 실행은 자동 스킵됩니다.`); + console.log(` 확인: systemctl --user status damn-my-slow-skt.timer`); + console.log(`\n 제거하려면: npx -y damn-my-slow-skt@latest schedule remove`); +} + +/** + * Synology NAS 전용 cron 설치. + * DSM은 user-level crontab이 없으므로 /etc/crontab을 직접 편집하고 + * synoservicectl --restart crond로 crond를 재시작한다. + * /etc/crontab 형식: minute hour mday month wday user command + */ +function installSynologyCron(config: Config, configPath: string): void { + const SYSTEM_CRONTAB = '/etc/crontab'; + const times = buildScheduleTimes(config); + const exec = getCliExec(); + const logPath = path.join(DATA_DIR, 'cron.log'); + const user = os.userInfo().username; + + const execCmd = [shellQuote(exec.program), ...exec.prefixArgs.map(shellQuote), 'run', '--config', shellQuote(configPath)].join(' '); + + // cron은 최소 PATH로 실행되므로 node/npx가 있는 디렉토리를 PATH에 명시해야 한다 + const nodeBinDir = path.dirname(process.execPath); + const pathPrefix = `PATH=${shellQuote(nodeBinDir)}:/usr/local/bin:/usr/bin:/bin`; + + // Synology /etc/crontab은 user 필드가 포함된 형식 + const cronLines = times.map((t) => + `${t.minute}\t${t.hour}\t*\t*\t*\t${user}\t${pathPrefix} ${execCmd} >> ${shellQuote(logPath)} 2>&1 ${CRON_COMMENT}` + ); + + let existing = ''; + try { + existing = fs.readFileSync(SYSTEM_CRONTAB, 'utf8'); + } catch { + throw new Error(`${SYSTEM_CRONTAB}을 읽을 수 없습니다. sudo 권한이 필요할 수 있습니다.`); + } + + // 기존 damn-my-slow-skt 라인 제거 후 새 라인 추가 + const lines = existing + .split('\n') + .filter((l) => !l.includes(CRON_COMMENT)); + + // 마지막 빈 줄 유지하면서 추가 + while (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + lines.push(...cronLines, ''); + + const newCrontab = lines.join('\n'); + + try { + fs.writeFileSync(SYSTEM_CRONTAB, newCrontab, 'utf8'); + } catch { + // sudo 실행 시 $HOME이 /root로 바뀌므로 --config로 원래 경로를 명시해야 한다 + throw new Error( + `/etc/crontab 쓰기 실패. sudo 권한으로 다시 시도하세요:\n` + + ` sudo npx --yes damn-my-slow-skt schedule install --config ${configPath}` + ); + } + + // crond 재시작으로 변경사항 반영 + try { + execSync('synoservicectl --restart crond 2>/dev/null', { stdio: 'ignore' }); + } catch { + // synoservicectl이 없으면 /usr/syno/bin/ 경로로 재시도 + try { + execSync('/usr/syno/bin/synoservicectl --restart crond 2>/dev/null', { stdio: 'ignore' }); + } catch { + console.log('⚠️ crond 재시작 실패 — NAS를 재부팅하면 반영됩니다.'); + } + } + + console.log(`✅ Synology /etc/crontab 등록 완료`); + console.log(` 매일 ${times.length}회 실행: ${formatScheduleTimes(times)}`); + console.log(` 감면 성공 시 나머지 실행은 자동 스킵됩니다.`); + console.log(`\n 제거하려면: sudo npx --yes damn-my-slow-skt schedule remove`); +} + +function installCron(config: Config, configPath: string): void { + const times = buildScheduleTimes(config); + const exec = getCliExec(); + const logPath = path.join(DATA_DIR, 'cron.log'); + + const execCmd = [shellQuote(exec.program), ...exec.prefixArgs.map(shellQuote), 'run', '--config', shellQuote(configPath)].join(' '); + + // cron은 최소 PATH로 실행되므로 node/npx가 있는 디렉토리를 PATH에 명시 + const nodeBinDir = path.dirname(process.execPath); + const pathPrefix = `PATH=${shellQuote(nodeBinDir)}:/usr/local/bin:/usr/bin:/bin`; + + // 각 트리거 시간마다 cron 라인 생성 + const cronLines = times.map((t) => + `${t.minute} ${t.hour} * * * ${pathPrefix} ${execCmd} >> ${shellQuote(logPath)} 2>&1 ${CRON_COMMENT}` + ); + + let existing = ''; + try { + existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' }); + } catch { + // no crontab + } + + // 기존 damn-my-slow-skt 라인 제거 후 새 라인 추가 + const lines = existing + .split('\n') + .filter((l) => !l.includes(CRON_COMMENT)); + lines.push(...cronLines); + + const newCrontab = lines.join('\n') + '\n'; + + // stdin 방식 먼저 시도 (표준 Linux) + const proc = require('child_process').spawnSync('crontab', ['-'], { + input: newCrontab, + encoding: 'utf8', + }); + + if (proc.status !== 0 || proc.error) { + // BusyBox(Synology NAS 등)는 crontab - (stdin) 미지원 → 임시 파일 방식으로 재시도 + const tmpFile = path.join(os.tmpdir(), `damn-my-slow-skt-cron-${Date.now()}.tmp`); + try { + fs.writeFileSync(tmpFile, newCrontab, 'utf8'); + const proc2 = require('child_process').spawnSync('crontab', [tmpFile], { + encoding: 'utf8', + }); + if (proc2.status !== 0 || proc2.error) { + const errMsg = + (proc2.stderr as string | undefined) || + proc2.error?.message || + (proc.stderr as string | undefined) || + proc.error?.message || + 'unknown error'; + throw new Error(`crontab 설치 실패: ${errMsg}`); + } + } finally { + try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + } + } + + console.log(`✅ crontab 등록 완료`); + console.log(` 매일 ${times.length}회 실행: ${formatScheduleTimes(times)}`); + console.log(` 감면 성공 시 나머지 실행은 자동 스킵됩니다.`); + console.log(`\n 제거하려면: npx -y damn-my-slow-skt@latest schedule remove`); +} + +export function removeLinux(): void { + if (fs.existsSync(SYSTEMD_SERVICE_PATH) || fs.existsSync(SYSTEMD_TIMER_PATH)) { + try { + execSync('systemctl --user stop damn-my-slow-skt.timer 2>/dev/null', { stdio: 'ignore' }); + execSync('systemctl --user disable damn-my-slow-skt.timer 2>/dev/null', { stdio: 'ignore' }); + } catch { + // ignore + } + + if (fs.existsSync(SYSTEMD_SERVICE_PATH)) fs.unlinkSync(SYSTEMD_SERVICE_PATH); + if (fs.existsSync(SYSTEMD_TIMER_PATH)) fs.unlinkSync(SYSTEMD_TIMER_PATH); + + try { + execSync('systemctl --user daemon-reload 2>/dev/null', { stdio: 'ignore' }); + } catch { + // ignore + } + + console.log('✅ systemd 타이머 제거 완료'); + return; + } + + // Synology NAS: /etc/crontab에서 제거 + if (isSynology()) { + try { + const SYSTEM_CRONTAB = '/etc/crontab'; + const existing = fs.readFileSync(SYSTEM_CRONTAB, 'utf8'); + const lines = existing.split('\n').filter((l) => !l.includes(CRON_COMMENT)); + fs.writeFileSync(SYSTEM_CRONTAB, lines.join('\n') + '\n', 'utf8'); + try { + execSync('synoservicectl --restart crond 2>/dev/null', { stdio: 'ignore' }); + } catch { + try { + execSync('/usr/syno/bin/synoservicectl --restart crond 2>/dev/null', { stdio: 'ignore' }); + } catch { /* ignore */ } + } + console.log('✅ Synology /etc/crontab 스케줄 제거 완료'); + } catch { + throw new Error('/etc/crontab 수정 실패. sudo 권한으로 다시 시도하세요:\n sudo npx --yes damn-my-slow-skt schedule remove'); + } + return; + } + + // 일반 Linux: crontab 명령어로 제거 + try { + const existing = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' }); + if (!existing.includes(CRON_COMMENT)) { + console.log('등록된 crontab 스케줄이 없습니다.'); + return; + } + const lines = existing.split('\n').filter((l) => !l.includes(CRON_COMMENT)); + const newCrontab = lines.join('\n') + '\n'; + const proc = require('child_process').spawnSync('crontab', ['-'], { input: newCrontab, encoding: 'utf8' }); + if (proc.status !== 0 || proc.error) { + throw new Error(`crontab 제거 실패: ${(proc.stderr as string | undefined) || proc.error?.message || 'unknown error'}`); + } + console.log('✅ crontab 스케줄 제거 완료'); + } catch (e: unknown) { + if (e instanceof Error && e.message.includes('crontab 제거 실패')) throw e; + console.log('등록된 crontab 스케줄이 없습니다.'); + } +} + +export function installSchedule(config: Config, configPath: string = DEFAULT_CONFIG_PATH): void { + const platform = getPlatform(); + + if (platform === 'macos') { + installMacos(config, configPath); + } else if (platform === 'linux') { + installLinux(config, configPath); + } else if (platform === 'windows') { + console.log(''); + const times = buildScheduleTimes(config); + console.log('Windows에서는 작업 스케줄러(Task Scheduler)를 사용하세요:'); + console.log('1. Win + R → taskschd.msc 입력'); + console.log('2. 기본 작업 만들기 클릭'); + console.log(`3. 프로그램: npx --yes damn-my-slow-skt run --config ${configPath}`); + console.log(`4. 트리거: 매일 ${formatScheduleTimes(times)} (${times.length}개 등록)`); + console.log(' (run 내부에서 오늘 완료 여부를 체크하므로 모두 등록해도 안전합니다)'); + } else { + throw new Error(`지원하지 않는 플랫폼: ${platform}`); + } +} + +export function removeSchedule(): void { + const platform = getPlatform(); + + if (platform === 'macos') { + removeMacos(); + } else if (platform === 'linux') { + removeLinux(); + } else { + console.log('이 플랫폼에서는 자동 제거가 지원되지 않습니다.'); + } +} diff --git a/src/skt.ts b/src/skt.ts new file mode 100644 index 0000000..c19b0c6 --- /dev/null +++ b/src/skt.ts @@ -0,0 +1,982 @@ +/** + * SKT/SK브로드밴드 자동화 - Myspeed 인터넷 SLA 속도측정 + * + * Flow (실제 테스트를 통해 검증된 플로우): + * 1. http://myspeed.skbroadband.com/mesu/internet_sla.asp 접속 + * 2. "품질보증(SLA) 테스트" 버튼 클릭 (class="redbtn btntolayer") → 레이어 팝업 + * 3. 레이어에서 회선 선택 (radio button - el-radio 컴포넌트, value="0") + * 4. "#measureBtn" 클릭 → 테스트 시작 + * 5. 5회 자동 측정 완료 대기 (각 300초 간격 → 총 ~25분) + * 6. 결과 파싱 (SLA pass/fail) + * 7. fail이면 "이의신청" 버튼 클릭 + * + * 로그인 플로우: + * - 로그인 없이 접속 → osms.bworld.co.kr으로 리다이렉트 + * - 로그인 후 비밀번호 변경 안내 → "다음에 하기" 클릭 (3개월 유예) + * - 로그인 완료 후 SLA 소개 페이지로 복귀 + */ + +import { Browser, BrowserContext, Page, chromium } from 'playwright'; +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { Config, DATA_DIR } from './config'; +import chalk from 'chalk'; + +const SKT_SLA_INTRO_URL = 'http://myspeed.skbroadband.com/mesu/internet_sla.asp'; +const SKT_SLA_START_URL = 'http://myspeed.skbroadband.com/mesu/sla/ipcheck.asp'; +// TEST_TIMEOUT_MIN 환경변수로 타임아웃 조절 가능 (기본 40분) +const SLA_TEST_TIMEOUT_MS = (parseInt(process.env.TEST_TIMEOUT_MIN || '0') || 40) * 60 * 1000; +const POLL_INTERVAL_MS = 15 * 1000; // 15초 — 라운드 변화를 빠르게 감지 + +// ─── 진행 UI 헬퍼 ──────────────────────────────────────────────── + +const STEPS = { + login: { num: 1, total: 5, label: '로그인' }, + layer: { num: 2, total: 5, label: 'SLA 테스트 준비' }, + measure: { num: 3, total: 5, label: '속도 측정' }, + parse: { num: 4, total: 5, label: '결과 분석' }, + action: { num: 5, total: 5, label: '감면 처리' }, +}; + +function stepHeader(step: { num: number; total: number; label: string }): void { + const bar = '●'.repeat(step.num) + '○'.repeat(step.total - step.num); + console.log(chalk.cyan(`\n ${bar} `) + chalk.bold(`[${step.num}/${step.total}] ${step.label}`)); +} + +function info(msg: string): void { + console.log(chalk.dim(` ${msg}`)); +} + +function formatElapsed(ms: number): string { + const min = Math.floor(ms / 60000); + const sec = Math.floor((ms % 60000) / 1000); + return min > 0 ? `${min}분 ${sec}초` : `${sec}초`; +} + +/** 측정 진행 바 (1~5회차) */ +function measureProgress(round: number, total: number, elapsedMs: number): void { + const filled = round; + const empty = total - round; + const bar = chalk.green('■'.repeat(filled)) + chalk.gray('□'.repeat(empty)); + const elapsed = formatElapsed(elapsedMs); + // 커서를 줄 앞으로 이동하여 같은 줄에 덮어쓰기 + if (process.stdout.isTTY) { + process.stdout.write(`\r ${bar} ${round}/${total}회 완료 ${chalk.dim(elapsed)} `); + } else { + console.log(` ${bar} ${round}/${total}회 완료 ${elapsed}`); + } +} + +export interface SpeedTestResult { + download_mbps: number; + upload_mbps: number; + ping_ms: number; + sla_result: 'pass' | 'fail' | 'unknown'; + complaint_filed: boolean; + complaint_result: 'success' | 'failed' | 'skipped' | 'not_applicable'; + raw_data: Record; + error: string; +} + +/** + * run() 실행 시 옵션. + * debug=true면 headless를 강제로 off, slowMo/devtools 켜고 + * 에러 발생 시 브라우저를 사용자가 Enter 칠 때까지 닫지 않는다. + */ +export interface RunOptions { + dryRun?: boolean; + debug?: boolean; +} + +function defaultResult(): SpeedTestResult { + return { + download_mbps: 0, + upload_mbps: 0, + ping_ms: 0, + sla_result: 'unknown', + complaint_filed: false, + complaint_result: 'skipped', + raw_data: {}, + error: '', + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class SKTProvider { + private config: Config; + private browser: Browser | null = null; + private context: BrowserContext | null = null; + private page: Page | null = null; + + constructor(config: Config) { + this.config = config; + } + + async run(dryRunOrOptions: boolean | RunOptions = false): Promise { + // 하위 호환: 기존 호출 방식(boolean)은 dryRun만 지정. + // 새 호출 방식은 { dryRun, debug } 객체. + const options: RunOptions = + typeof dryRunOrOptions === 'boolean' ? { dryRun: dryRunOrOptions } : dryRunOrOptions; + const dryRun = options.dryRun === true; + const debug = options.debug === true; + + const result = defaultResult(); + + // 디버그 모드: 브라우저 창을 열고 각 동작을 slowMo로 느리게 실행. + // 원인 추정이 어려운 상황(이슈 #3의 신규 기기 등록/다회선 주소지 선택/회선 미보유 등)에서 + // 사용자가 직접 브라우저를 관찰할 수 있게 한다. config.headless 값을 덮어씀. + const headless = debug ? false : this.config.headless; + const launchOptions = { + headless, + slowMo: debug ? 250 : 0, + devtools: debug, + args: [ + '--no-sandbox', + '--disable-blink-features=AutomationControlled', + '--use-fake-ui-for-media-stream', + '--disable-web-security', + ], + }; + + // Playwright 브라우저 바이너리가 없으면 자동 설치 (npx 첫 실행 시 필요) + try { + this.browser = await chromium.launch(launchOptions); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + if (err.message.includes("Executable doesn't exist")) { + console.log('📦 Chromium 브라우저 설치 중... (최초 1회)'); + execSync('npx playwright install chromium', { stdio: 'inherit' }); + this.browser = await chromium.launch(launchOptions); + } else { + throw e; + } + } + + this.context = await this.createContext(); + + try { + this.page = await this.context.newPage(); + + // Step 1: 로그인 + stepHeader(STEPS.login); + info('myspeed.skbroadband.com 접속 중...'); + await this.page.goto(SKT_SLA_INTRO_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await sleep(2000); + await this.handleLogin(); + + const currentUrl = this.page.url(); + if (!currentUrl.includes('/mesu/internet_sla.asp') && !currentUrl.includes('/mesu/sla/ipcheck.asp')) { + info('SLA 페이지로 이동 중...'); + await this.page.goto(SKT_SLA_INTRO_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await sleep(2000); + } + info('로그인 완료'); + + // Step 2: SLA 테스트 준비 + stepHeader(STEPS.layer); + info('품질보증(SLA) 테스트 레이어 열기...'); + await this.openSlaLayer(); + info('회선 선택 중...'); + await this.selectLine(); + info('준비 완료'); + + // Step 3: 속도 측정 + stepHeader(STEPS.measure); + info('5회 측정 시작 (약 25분 소요)'); + await this.startMeasurement(); + await this.waitForCompletion(); + + // Step 4: 결과 분석 + stepHeader(STEPS.parse); + info('측정 데이터 파싱 중...'); + const parsed = await this.parseResults(); + Object.assign(result, parsed); + + // Step 5: 감면 처리 + stepHeader(STEPS.action); + if (result.sla_result === 'fail' && !dryRun) { + info('SLA 미달 → 이의신청 진행...'); + const ok = await this.fileComplaint(); + result.complaint_filed = ok; + result.complaint_result = ok ? 'success' : 'failed'; + info(ok ? '이의신청 완료' : '이의신청 실패'); + } else if (result.sla_result === 'fail' && dryRun) { + info('SLA 미달 (dry-run → 이의신청 생략)'); + result.complaint_result = 'skipped'; + } else if (result.sla_result === 'pass') { + info('SLA 통과 → 이의신청 불필요'); + result.complaint_result = 'not_applicable'; + } + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + info(chalk.red(`오류: ${err.message}`)); + result.error = err.message; + result.sla_result = 'unknown'; + + // 오류 스크린샷 + try { + await this.page?.screenshot({ path: 'skt-error.png' }); + info('스크린샷 저장: skt-error.png'); + } catch { + // ignore + } + + // 디버그 모드 + 인터랙티브 TTY에서는 브라우저를 열어둔 채 사용자 확인 대기. + // 이슈 #3 같은 환경별 엣지 케이스를 눈으로 확인하기 위함. + if (debug && process.stdin.isTTY && process.stdout.isTTY) { + await this.waitForEnter( + chalk.yellow('\n🔍 디버그 모드: 브라우저에서 현재 상태를 확인하세요.\n') + + chalk.dim(' 확인 후 Enter를 누르면 브라우저를 닫습니다... '), + ); + } + } finally { + await this.context?.close(); + await this.browser?.close(); + this.browser = null; + this.context = null; + this.page = null; + } + + return result; + } + + async login(): Promise { + const launchOptions = { + headless: false, + slowMo: 100, + args: ['--no-sandbox', '--disable-blink-features=AutomationControlled'], + }; + + try { + this.browser = await chromium.launch(launchOptions); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + if (err.message.includes("Executable doesn't exist")) { + console.log('📦 Chromium 브라우저 설치 중... (최초 1회)'); + execSync('npx playwright install chromium', { stdio: 'inherit' }); + this.browser = await chromium.launch(launchOptions); + } else { + throw e; + } + } + + this.context = await this.createContext(); + this.page = await this.context.newPage(); + + console.log(chalk.cyan('\n🔐 B world/T아이디 로그인 세션 생성')); + console.log(chalk.dim(' 열린 브라우저에서 B world/T아이디 또는 간편인증 로그인을 완료하세요.')); + console.log(chalk.dim(' SLA 시작 페이지 또는 Myspeed 페이지로 돌아온 뒤 터미널에서 Enter를 누르세요.')); + + try { + await this.page.goto(SKT_SLA_START_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await this.waitForEnter(chalk.yellow('\n로그인을 완료했으면 Enter를 누르세요... ')); + await this.page.goto(SKT_SLA_START_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await sleep(2000); + + if (this.isLoginUrl(this.page.url()) || (await this.isLoginPageVisible())) { + throw new Error('로그인이 완료되지 않았습니다. 브라우저에서 인증을 마친 뒤 다시 시도하세요.'); + } + + fs.mkdirSync(path.dirname(this.config.auth_state_path), { recursive: true }); + await this.context.storageState({ path: this.config.auth_state_path }); + console.log(chalk.green(`✅ 로그인 세션 저장 완료: ${this.config.auth_state_path}`)); + console.log(chalk.dim(' 이제 headless run에서 이 세션을 재사용합니다. 만료되면 auth login을 다시 실행하세요.')); + } finally { + await this.context?.close(); + await this.browser?.close(); + this.browser = null; + this.context = null; + this.page = null; + } + } + + private async createContext(): Promise { + const storageState = fs.existsSync(this.config.auth_state_path) + ? this.config.auth_state_path + : undefined; + + return this.browser!.newContext({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/123.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 900 }, + storageState, + }); + } + + /** + * 디버그 모드에서 사용자가 브라우저를 관찰할 시간을 주기 위해 + * Enter 입력을 기다린다. TTY가 아니면 즉시 반환. + */ + private waitForEnter(prompt: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY) { + resolve(); + return; + } + process.stdout.write(prompt); + const onData = () => { + process.stdin.removeListener('data', onData); + process.stdin.pause(); + resolve(); + }; + process.stdin.resume(); + process.stdin.once('data', onData); + }); + } + + private isLoginUrl(currentUrl: string): boolean { + return currentUrl.includes('osms.bworld.co.kr') || currentUrl.includes('t-id.co.kr'); + } + + private async isLoginPageVisible(): Promise { + try { + return await this.page!.locator('#btnLogin, #loginForm, #loginForm2').first().isVisible({ timeout: 500 }); + } catch { + return false; + } + } + + private async handleLogin(): Promise { + const page = this.page!; + const { id, password } = this.config.credentials; + + if (!id || !password) { + throw new Error('SKT/B world 계정 정보가 설정되지 않았습니다. 설정 파일을 확인하세요.'); + } + + const url = page.url(); + if (!this.isLoginUrl(url) && !(await this.isLoginPageVisible())) { + return; + } + + info('B world/T아이디 로그인 페이지 감지...'); + await this.fillLoginForm(id, password); + + // 로그인 후 리다이렉트 대기 — osms.bworld.co.kr에서 벗어날 때까지 + try { + await page.waitForURL((url) => !this.isLoginUrl(url.toString()), { timeout: 15000 }); + } catch { + // 비밀번호 변경 등 중간 페이지에서 멈출 수 있음 + } + await sleep(2000); + + const afterUrl = page.url(); + if (afterUrl.includes('unchanged-password') || afterUrl.includes('change-password')) { + info('비밀번호 변경 안내 → 다음에 하기'); + try { + await page.waitForSelector('button', { timeout: 5000 }); + await page.evaluate(() => { + const btns = document.querySelectorAll('button'); + for (const btn of btns) { + const text = btn.textContent || ''; + if (text.includes('다음에 하기') || text.includes('나중에') || text.includes('Skip')) { + btn.click(); + return; + } + } + }); + await sleep(3000); + } catch { + // 다음에 하기 버튼 없음, 계속 진행 + } + } + } + + private async openSlaLayer(): Promise { + const page = this.page!; + const { id, password } = this.config.credentials; + + // SK브로드밴드 Myspeed는 소개 페이지의 시작 링크를 통해 SLA 측정/로그인 단계로 이동한다. + const btnExists = await page.evaluate(() => { + return !!document.querySelector('a.bann-btn[href*="/mesu/sla/ipcheck.asp"], a[href*="/mesu/sla/ipcheck.asp"]'); + }); + + if (btnExists) { + await page.click('a.bann-btn[href*="/mesu/sla/ipcheck.asp"], a[href*="/mesu/sla/ipcheck.asp"]'); + } else { + await page.goto(SKT_SLA_START_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); + } + await sleep(3000); + + // 로그인 페이지로 리다이렉트 되었는지 확인 + const currentUrl = page.url(); + if (this.isLoginUrl(currentUrl) || (await this.isLoginPageVisible())) { + info('로그인 필요 → 로그인 진행'); + await this.fillLoginForm(id, password); + + // 로그인 후 리다이렉트 대기 + try { + await page.waitForURL((url) => !this.isLoginUrl(url.toString()), { timeout: 15000 }); + } catch { + // 비밀번호 변경 안내 등 중간 페이지에서 멈출 수 있음 + } + await sleep(2000); + + // 비밀번호 변경 안내 처리 + const afterUrl = page.url(); + if (afterUrl.includes('unchanged-password') || afterUrl.includes('change-password')) { + info('비밀번호 변경 안내 → 다음에 하기'); + await page.evaluate(() => { + const btns = document.querySelectorAll('button'); + for (const btn of btns) { + if ((btn.textContent || '').includes('다음에 하기')) { + btn.click(); + return; + } + } + }); + await sleep(3000); + } + + // 로그인 후 SK브로드밴드 SLA 시작 페이지로 재접속 + if (!page.url().includes('/mesu/sla/ipcheck.asp')) { + await page.goto(SKT_SLA_START_URL, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await sleep(2000); + } + } + + // 레이어가 열렸는지 확인 — Vue 컴포넌트가 #ifArea에 회선 정보를 렌더링 + const layerText = await page.evaluate(() => { + return document.getElementById('ifArea')?.textContent?.trim().slice(0, 200) || ''; + }); + + if (!layerText) { + throw new Error('로그인 후에도 SLA 레이어가 열리지 않았습니다'); + } + + info('SLA 레이어 열림'); + } + + private async fillLoginForm(id: string, password: string): Promise { + const page = this.page!; + + // osms.bworld.co.kr 로그인 폼: input#id (아이디), input#password (비밀번호) + // 구버전 호환을 위해 generic selector도 fallback으로 유지 + const idSelectors = ['input#id', "input[name='id']", "input[type='text']"]; + const pwSelectors = ['input#password', "input[name='password']", "input[type='password']"]; + + let idFilled = false; + for (const sel of idSelectors) { + try { + await page.waitForSelector(sel, { timeout: 5000 }); + await page.fill(sel, id); + info(`계정: ${id}`); + idFilled = true; + break; + } catch { + continue; + } + } + if (!idFilled) { + const tIdLogin = page.locator('#btnLogin').first(); + if (await tIdLogin.isVisible({ timeout: 500 }).catch(() => false)) { + await tIdLogin.click(); + await sleep(3000); + for (const sel of idSelectors) { + try { + await page.waitForSelector(sel, { timeout: 5000 }); + await page.fill(sel, id); + info(`계정: ${id}`); + idFilled = true; + break; + } catch { + continue; + } + } + } + } + + if (!idFilled) { + throw new Error('저장된 로그인 세션이 없거나 만료되었습니다. 먼저 `npx -y damn-my-slow-skt@latest auth login`을 실행하세요.'); + } + + for (const sel of pwSelectors) { + try { + await page.waitForSelector(sel, { timeout: 3000 }); + await page.fill(sel, password); + break; + } catch { + continue; + } + } + + // 로그인 버튼 클릭 — Playwright의 click()으로 안정적인 클릭 + try { + const loginBtn = page.locator('button[type="submit"]').filter({ hasText: '로그인' }); + await loginBtn.waitFor({ state: 'visible', timeout: 3000 }); + await loginBtn.click(); + } catch { + // fallback: evaluate로 직접 클릭 + try { + await page.evaluate(() => { + const btns = document.querySelectorAll('button, input[type="submit"]'); + for (const btn of btns) { + const text = (btn as HTMLElement).textContent || (btn as HTMLInputElement).value || ''; + if (text.includes('로그인')) { + (btn as HTMLElement).click(); + return; + } + } + }); + } catch { + // 로그인 버튼 없음 + } + } + } + + private async selectLine(): Promise { + const page = this.page!; + + const result = await page.evaluate(() => { + // Element UI 라디오 — 첫 번째 회선이 기본 선택됨 + const radioLabel = document.querySelector('label.el-radio.addr') as HTMLElement | null; + if (radioLabel) { + radioLabel.click(); // Element UI는 label 클릭으로 선택 처리 + + // 회선 정보 텍스트 추출 (상품명 - 주소) + const labelText = radioLabel.querySelector('.el-radio__label')?.textContent?.trim() || ''; + return labelText || 'selected (no label)'; + } + + // fallback: generic radio + const radioInput = document.querySelector('input[type="radio"]') as HTMLInputElement | null; + if (radioInput) { + radioInput.checked = true; + radioInput.dispatchEvent(new Event('change', { bubbles: true })); + const label = radioInput.closest('label'); + if (label) (label as HTMLElement).click(); + return radioInput.value; + } + return 'no radio found'; + }); + + if (result === 'no radio found') { + // SLA 단계에 진입했지만 회선 라디오 버튼이 없음 = 이 계정에 SK브로드밴드 회선이 연결되어 있지 않거나 인증 후 회선 선택 화면이 달라짐. + // #measureBtn 단계까지 내려가기 전에 여기서 명확한 원인으로 끊어야 + // 다른 엣지 케이스(속도측정 프로그램 미설치, 새 기기 등록 등)와 혼동되지 않음. + throw new Error( + 'SK브로드밴드 회선 정보를 찾을 수 없습니다. 이 계정에 SK브로드밴드 인터넷 회선이 연결되어 있는지 확인하세요. ' + + '(회선이 없는 계정으로는 SLA 측정이 불가능합니다)', + ); + } + + if (result) { + info(`회선: ${result}`); + } + + await sleep(500); + } + + private async startMeasurement(): Promise { + const page = this.page!; + + // #measureBtn (a.speed_speedtest_prestart_btn) 클릭 — Vue 컴포넌트가 SLA 테스트 시작 + const btn = page.locator('#measureBtn, a.speed_speedtest_prestart_btn').first(); + try { + await btn.waitFor({ state: 'visible', timeout: 5000 }); + await btn.click(); + } catch { + // 회선 미보유 케이스는 selectLine() 단계에서 이미 걸러짐. + // 여기까지 와서 버튼이 안 뜨는 건 그 외 원인 (이슈 #3 참고). + throw new Error( + '속도 측정 시작 버튼(#measureBtn)을 찾지 못했습니다. 자주 발생하는 원인:\n' + + ' • SK브로드밴드 속도측정 프로그램 미설치 또는 브라우저 에이전트 차단\n' + + ' • 새 기기 등록 화면이 추가로 뜸\n' + + ' • 다회선 계정에서 주소지 선택 화면이 추가로 뜸\n' + + ' 디버그 모드로 원인 확인: npx -y damn-my-slow-skt@latest run --debug', + ); + } + + await sleep(5000); + + // 측정이 시작되었는지 확인 — "회차 측정중" 또는 결과 테이블이 나타나야 함 + const layerText = await page.evaluate(() => { + return ( + document + .getElementById('ifArea') + ?.textContent?.replace(/\s+/g, ' ') + .trim() + .slice(0, 300) || '' + ); + }); + + if (layerText.includes('측정중') || layerText.includes('SLA 테스트')) { + info('측정 시작 확인'); + } else { + info('측정 시작 대기 중...'); + } + + // 단말 정보 출력 — 페이지 하단의 품질측정 단말정보 + const deviceInfo = await page.evaluate(() => { + const ifArea = document.getElementById('ifArea'); + if (!ifArea) return null; + const text = ifArea.textContent || ''; + const osMatch = text.match(/OS\s+([\s\S]*?)(?=CPU)/); + const cpuMatch = text.match(/CPU\s+([\s\S]*?)(?=RAM)/); + const ramMatch = text.match(/RAM\s+([\s\S]*?)(?=Browser)/); + const browserMatch = text.match(/Browser\s+([\s\S]*?)(?=재측정|$)/); + return { + os: osMatch ? osMatch[1].trim() : '', + cpu: cpuMatch ? cpuMatch[1].trim() : '', + ram: ramMatch ? ramMatch[1].trim() : '', + browser: browserMatch ? browserMatch[1].trim() : '', + }; + }); + + if (deviceInfo && deviceInfo.os) { + info(chalk.cyan('품질측정 단말정보:')); + info(` OS: ${deviceInfo.os}`); + info(` CPU: ${deviceInfo.cpu}`); + info(` RAM: ${deviceInfo.ram}`); + info(` Browser: ${deviceInfo.browser}`); + } + + // HTML 캡처 저장 (디버그/증거용) + await this.saveHtmlSnapshot('measurement-start'); + } + + private async waitForCompletion(): Promise { + const page = this.page!; + const maxWaitMs = SLA_TEST_TIMEOUT_MS; + let elapsed = 0; + let lastReportedRound = 0; // 이미 출력한 라운드 추적 + + while (elapsed < maxWaitMs) { + await sleep(POLL_INTERVAL_MS); + elapsed += POLL_INTERVAL_MS; + + // 구조화된 CSS 클래스로 회차별 결과를 직접 파싱 + const status = await page.evaluate(() => { + const ifArea = document.getElementById('ifArea'); + if (!ifArea) return null; + + // 회차별 상세 결과 + const rounds: Array<{ speed: string; slaRef: string; result: string; date: string }> = []; + for (let i = 1; i <= 5; i++) { + const speed = ifArea.querySelector(`.step-table-speed-${i}`)?.textContent?.trim() || ''; + const slaRef = ifArea.querySelector(`.step-table-default-${i}`)?.textContent?.trim() || ''; + const resultText = ifArea.querySelector(`.step-table-result-${i}`)?.textContent?.trim() || ''; + const date = ifArea.querySelector(`.step-table-date-${i}`)?.textContent?.trim() || ''; + rounds.push({ speed, slaRef, result: resultText, date }); + } + + const completedRounds = rounds.filter(r => r.speed).length; + + // "측정중" 상태 확인 + const fullText = ifArea.textContent?.replace(/\s+/g, ' ').trim() || ''; + const isMeasuring = fullText.includes('측정중'); + + // 카운트다운 타이머 + const countdown = ifArea.querySelector('.delayTimeSec')?.textContent?.trim() || ''; + + // 결과 요약 텍스트 + const totalMatch = fullText.match(/테스트\s*횟수\s*(\d+)\s*번/); + const totalCount = totalMatch ? parseInt(totalMatch[1]) : 0; + + return { rounds, completedRounds, isMeasuring, countdown, totalCount, textSnippet: fullText.slice(0, 200) }; + }); + + if (!status) continue; + + if (process.env.DEBUG_POLL) { + console.log(`\n[DEBUG POLL ${formatElapsed(elapsed)}] rounds=${status.completedRounds} measuring=${status.isMeasuring} countdown=${status.countdown} total=${status.totalCount}`); + console.log(` text: ${status.textSnippet}`); + } + + // 새로 완료된 라운드가 있으면 즉시 결과 출력 + if (status.completedRounds > lastReportedRound) { + for (let i = lastReportedRound; i < status.completedRounds; i++) { + const r = status.rounds[i]; + const isFail = r.result.includes('미달'); + const icon = isFail ? '❌' : '✅'; + if (process.stdout.isTTY) console.log(''); // 진행 바 줄바꿈 + info(`${icon} ${i + 1}회차: ${r.speed} (기준 ${r.slaRef}) → ${r.result} [${r.date}]`); + } + lastReportedRound = status.completedRounds; + + // HTML 스냅샷 저장 + await this.saveHtmlSnapshot(`round-${status.completedRounds}`); + } + + const roundsDone = status.completedRounds || status.totalCount; + + // 완료 조건: 5개 회차의 측정값이 모두 채워짐 + // (페이지가 "측정중" 텍스트를 유지하더라도, 5개 속도값이 있으면 완료) + if (status.completedRounds >= 5) { + measureProgress(5, 5, elapsed); + if (process.stdout.isTTY) console.log(''); + info('5회 측정 완료!'); + await this.saveHtmlSnapshot('complete'); + break; + } else if (roundsDone > 0) { + measureProgress(roundsDone, 5, elapsed); + if (status.countdown) { + if (process.stdout.isTTY) { + process.stdout.write(chalk.dim(` 다음: ${status.countdown}`)); + } + } + } + } + + if (elapsed >= maxWaitMs) { + if (process.stdout.isTTY) console.log(''); + info(chalk.yellow(`⏰ ${Math.round(maxWaitMs / 60000)}분 타임아웃 - 현재 결과로 진행`)); + await this.saveHtmlSnapshot('timeout'); + } + } + + private async parseResults(): Promise> { + const page = this.page!; + const result: Partial = { + download_mbps: 0, + upload_mbps: 0, + ping_ms: 0, + sla_result: 'unknown', + raw_data: {}, + error: '', + }; + + try { + // 구조화된 DOM에서 회차별 데이터를 직접 추출 + const parsed = await page.evaluate(() => { + const ifArea = document.getElementById('ifArea'); + if (!ifArea) return null; + + // 회차별 결과 파싱 — CSS 클래스 기반 + const rounds: Array<{ speed: string; slaRef: string; result: string; date: string }> = []; + for (let i = 1; i <= 5; i++) { + const speed = ifArea.querySelector(`.step-table-speed-${i}`)?.textContent?.trim() || ''; + const slaRef = ifArea.querySelector(`.step-table-default-${i}`)?.textContent?.trim() || ''; + const resultText = ifArea.querySelector(`.step-table-result-${i}`)?.textContent?.trim() || ''; + const date = ifArea.querySelector(`.step-table-date-${i}`)?.textContent?.trim() || ''; + if (speed) { + rounds.push({ speed, slaRef, result: resultText, date }); + } + } + + // 요약 텍스트 (display:none이어도 textContent로 접근 가능) + const fullText = ifArea.textContent?.replace(/\s+/g, ' ').trim() || ''; + const satisfyMatch = fullText.match(/SLA만족\s*횟수는?\s*(\d+)\s*번/); + const failMatch = fullText.match(/미달\s*횟수는?\s*(\d+)\s*번/); + const totalMatch = fullText.match(/테스트\s*횟수\s*(\d+)\s*번/); + + return { + rounds, + satisfyCount: satisfyMatch ? parseInt(satisfyMatch[1]) : 0, + failCount: failMatch ? parseInt(failMatch[1]) : 0, + totalCount: totalMatch ? parseInt(totalMatch[1]) : 0, + fullText: fullText.slice(0, 500), + }; + }); + + if (!parsed) { + result.error = 'ifArea 엘리먼트를 찾지 못했습니다'; + return result; + } + + // 회차별 속도를 평균으로 계산 + const speeds = parsed.rounds + .map((r) => parseFloat(r.speed)) + .filter((v) => !isNaN(v)); + + if (speeds.length > 0) { + result.download_mbps = speeds.reduce((a, b) => a + b, 0) / speeds.length; + } + + // SLA 결과 판정 + const { satisfyCount, failCount, totalCount } = parsed; + if (totalCount > 0) { + info(`전체 ${totalCount}회: 만족 ${satisfyCount}회, 미달 ${failCount}회`); + + result.raw_data = { + total: totalCount, + satisfy: satisfyCount, + fail: failCount, + rounds: parsed.rounds, + }; + + // 5회 중 3회 이상 미달이면 SLA fail + if (failCount >= 3) { + result.sla_result = 'fail'; + } else { + result.sla_result = 'pass'; + } + } + + // 개별 라운드 결과 출력 + for (const round of parsed.rounds) { + const isFail = round.result.includes('미달'); + const icon = isFail ? '❌' : '✅'; + info(` ${icon} ${round.speed} (기준: ${round.slaRef}) → ${round.result}`); + } + + // fallback: 텍스트 기반 판정 + if (result.sla_result === 'unknown') { + if (parsed.fullText.includes('미달') && /[345]번/.test(parsed.fullText)) { + result.sla_result = 'fail'; + } else if (parsed.fullText.includes('만족')) { + result.sla_result = 'pass'; + } + } + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + info(chalk.red(`결과 파싱 실패: ${err.message}`)); + result.error = err.message; + } + + return result; + } + + /** + * 5회 측정 완료 후 "속도측정 상세이력" 다이얼로그가 자동으로 뜸. + * 다이얼로그에서: + * 1. 측정 결과 상세 정보를 CLI에 출력 (증거) + * 2. 전화번호를 입력 (config.phone) + * 3. "확인" 버튼 클릭 → 이의신청(품질점검 신청) 완료 + */ + private async fileComplaint(): Promise { + const page = this.page!; + + // 상세이력 다이얼로그가 열릴 때까지 대기 + try { + await page.waitForSelector('.slaTestResultDetailPopup', { state: 'visible', timeout: 30000 }); + } catch { + info('상세이력 다이얼로그가 열리지 않았습니다'); + return false; + } + + await sleep(2000); + await this.saveHtmlSnapshot('complaint-dialog'); + + // 상세이력 정보를 CLI에 출력 + const detail = await page.evaluate(() => { + const popup = document.querySelector('.slaTestResultDetailPopup'); + if (!popup) return null; + + // 요약 테이블 (test_table type1) 파싱 + const summaryRows = popup.querySelectorAll('.test_table.type1 tr'); + const summary: Record = {}; + summaryRows.forEach(row => { + const th = row.querySelector('th')?.textContent?.trim() || ''; + const td = row.querySelector('td')?.textContent?.trim() || ''; + if (th) summary[th] = td; + }); + + // 회차별 속도 테이블 (test_table type3) 파싱 + const speedRows = popup.querySelectorAll('.test_table.type3 tbody tr'); + const rounds: Array<{ round: string; speed: string }> = []; + speedRows.forEach(row => { + const cells = row.querySelectorAll('td'); + if (cells.length >= 2) { + rounds.push({ + round: cells[0].textContent?.trim() || '', + speed: cells[1].textContent?.trim() || '', + }); + } + }); + + return { summary, rounds }; + }); + + if (detail) { + console.log(''); + info(chalk.cyan('━━━ SLA 테스트 결과 상세 ━━━')); + info(` 측정일자: ${detail.summary['측정일자'] || '-'}`); + info(` 상품명: ${detail.summary['상품명'] || '-'}`); + info(` SLA기준속도: ${detail.summary['SLA기준속도'] || '-'}`); + info(` 측정횟수: ${detail.summary['측정횟수'] || '-'}`); + info(` 미달횟수: ${detail.summary['미달횟수'] || '-'}`); + info(` 결과: ${detail.summary['결 과'] || '-'}`); + info(''); + info(' 회차별 다운로드 속도:'); + for (const r of detail.rounds) { + info(` ${r.round}회차: ${r.speed} Mbps`); + } + info(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━')); + } + + // 전화번호 입력 — config.phone에서 가져옴 + const phone = this.config.phone || ''; + if (!phone) { + info(chalk.yellow('전화번호가 설정되지 않아 이의신청을 진행할 수 없습니다.')); + info(chalk.dim('설정 파일에 phone: "01012345678" 을 추가하세요.')); + return false; + } + + // 010-XXXX-XXXX 형태로 파싱 + const digits = phone.replace(/-/g, ''); + const prefix = digits.slice(0, 3); // 010 + const mid = digits.slice(3, 7); // 중간 4자리 + const last = digits.slice(7, 11); // 끝 4자리 + + info(`연락처 입력: ${prefix}-${mid}-${last}`); + + // 휴대폰 라디오 선택 (기본이 hp이지만 명시적으로) + await page.evaluate(() => { + const hpRadio = document.querySelector('input[type="radio"][value="hp"]') as HTMLInputElement; + if (hpRadio) { + const label = hpRadio.closest('label'); + if (label) label.click(); + } + }); + + // 중간번호, 끝번호 입력 + try { + await page.fill('input[name="telnum2"]', mid); + await page.fill('input[name="telnum3"]', last); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + info(chalk.red(`전화번호 입력 실패: ${err.message}`)); + return false; + } + + await sleep(1000); + + // "확인" 버튼 클릭 + try { + await page.click('a.sla_popup_detail_confirmCompAction_btn'); + info('품질점검 신청 확인 클릭'); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + info(chalk.red(`확인 버튼 클릭 실패: ${err.message}`)); + return false; + } + + await sleep(3000); + await this.saveHtmlSnapshot('complaint-submitted'); + + return true; + } + + async takeScreenshot(filePath = 'screenshot.png'): Promise { + if (this.page) { + await this.page.screenshot({ path: filePath }); + console.log(`스크린샷 저장: ${filePath}`); + } + } + + /** #ifArea의 HTML을 파일로 저장 — 디버그/증거용 */ + private async saveHtmlSnapshot(label: string): Promise { + try { + const snapshotDir = path.join(DATA_DIR, 'snapshots'); + fs.mkdirSync(snapshotDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filePath = path.join(snapshotDir, `${timestamp}_${label}.html`); + + const html = await this.page!.evaluate(() => { + return document.getElementById('ifArea')?.innerHTML || document.body.innerHTML; + }); + + fs.writeFileSync(filePath, html, 'utf8'); + } catch { + // 스냅샷 저장 실패는 무시 — 측정 플로우에 영향 없음 + } + } +} diff --git a/src/updater.ts b/src/updater.ts new file mode 100644 index 0000000..9ffbdf5 --- /dev/null +++ b/src/updater.ts @@ -0,0 +1,102 @@ +/** + * 자동 업데이트 체크 - npm registry에서 최신 버전 확인 + * 24시간에 1번만 체크 (캐시) + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import axios from 'axios'; +import chalk from 'chalk'; + +const CACHE_FILE = path.join(os.homedir(), '.damn-my-slow-isp', 'update-cache.json'); +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24시간 +const PACKAGE_NAME = 'damn-my-slow-skt'; + +interface UpdateCache { + lastCheck: number; + latestVersion: string; +} + +function readCache(): UpdateCache | null { + try { + if (!fs.existsSync(CACHE_FILE)) return null; + const raw = fs.readFileSync(CACHE_FILE, 'utf8'); + return JSON.parse(raw) as UpdateCache; + } catch { + return null; + } +} + +function writeCache(data: UpdateCache): void { + try { + const dir = path.dirname(CACHE_FILE); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf8'); + } catch { + // ignore cache write errors + } +} + +async function fetchLatestVersion(): Promise { + try { + const resp = await axios.get(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { + timeout: 5000, + }); + return resp.data?.version || null; + } catch { + return null; + } +} + +function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + const na = pa[i] || 0; + const nb = pb[i] || 0; + if (na !== nb) return na - nb; + } + return 0; +} + +export async function checkForUpdates( + currentVersion: string, + options: { noUpdateCheck?: boolean; interactive?: boolean } = {} +): Promise { + if (options.noUpdateCheck) return; + + const cache = readCache(); + const now = Date.now(); + + // 24시간 이내 체크했으면 스킵 + if (cache && now - cache.lastCheck < CHECK_INTERVAL_MS) { + const latestVersion = cache.latestVersion; + if (latestVersion && compareVersions(latestVersion, currentVersion) > 0) { + printUpdateNotice(currentVersion, latestVersion); + } + return; + } + + const latestVersion = await fetchLatestVersion(); + if (!latestVersion) return; + + writeCache({ lastCheck: now, latestVersion }); + + if (compareVersions(latestVersion, currentVersion) > 0) { + printUpdateNotice(currentVersion, latestVersion); + } +} + +function printUpdateNotice(current: string, latest: string): void { + console.log(''); + console.log( + chalk.yellow('🔄 새 버전이 있습니다:') + + chalk.dim(` v${current}`) + + chalk.yellow(' → ') + + chalk.green(`v${latest}`) + ); + console.log(chalk.dim(' 업데이트하려면:')); + console.log(chalk.cyan(` npm install -g ${PACKAGE_NAME}@latest`)); + console.log(''); +} diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..5b57780 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,187 @@ +/** + * config.ts 단위 테스트 + * - getDefaultConfig()가 올바른 기본값을 반환하는지 확인 + * - 설정 로드/저장은 파일 I/O가 포함되므로 통합 테스트에 가까움 + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import inquirer from 'inquirer'; +import { checkAndRunMigrations, CURRENT_CONFIG_VERSION } from '../src/migration'; +import { + getDefaultConfig, + loadConfig, + saveConfig, + SKT_TERMS_URLS, + SKT_TERMS_VERSION, + validateRequiredFields, +} from '../src/config'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('getDefaultConfig', () => { + it('should return valid default config with required fields', () => { + const config = getDefaultConfig(); + + expect(config._config_version).toBe(4); + expect(config.credentials).toBeDefined(); + expect(config.terms).toEqual({ + provider: 'skt', + accepted: false, + accepted_at: '', + version: SKT_TERMS_VERSION, + urls: SKT_TERMS_URLS, + }); + expect(config.plan.speed_mbps).toBe(1000); + expect(config.schedule.timezone).toBe('Asia/Seoul'); + expect(config.headless).toBe(true); + }); + + it('should have schedule with multi-attempt defaults', () => { + const config = getDefaultConfig(); + + expect(config.schedule.max_attempts).toBeGreaterThan(1); + expect(config.schedule.retry_interval_minutes).toBeGreaterThan(0); + expect(config.schedule.stop_on_complaint_success).toBe(true); + }); +}); + +describe('validateRequiredFields', () => { + it('should require SKT terms acceptance', () => { + const config = getDefaultConfig(); + config.credentials.id = 'user@example.com'; + config.credentials.password = 'password'; + config.phone = '01012345678'; + + expect(validateRequiredFields(config)).toContain( + 'terms (SKT/SK브로드밴드 공식 이용약관 동의)', + ); + }); + + it('should reject invalid SKT terms acceptance timestamps', () => { + const config = getDefaultConfig(); + config.credentials.id = 'user@example.com'; + config.credentials.password = 'password'; + config.phone = '01012345678'; + config.terms = { + provider: 'skt', + accepted: true, + accepted_at: 'not-a-date', + version: SKT_TERMS_VERSION, + urls: SKT_TERMS_URLS, + }; + + expect(validateRequiredFields(config)).toContain( + 'terms (SKT/SK브로드밴드 공식 이용약관 동의)', + ); + }); + + + it('should reject normalized or underspecified timestamp strings', () => { + const config = getDefaultConfig(); + config.credentials.id = 'user@example.com'; + config.credentials.password = 'password'; + config.phone = '01012345678'; + config.terms = { + provider: 'skt', + accepted: true, + accepted_at: '1', + version: SKT_TERMS_VERSION, + urls: SKT_TERMS_URLS, + }; + + expect(validateRequiredFields(config)).toContain( + 'terms (SKT/SK브로드밴드 공식 이용약관 동의)', + ); + + config.terms.accepted_at = '2026-02-31T00:00:00.000Z'; + + expect(validateRequiredFields(config)).toContain( + 'terms (SKT/SK브로드밴드 공식 이용약관 동의)', + ); + }); + + it('should accept config with current SKT terms metadata', () => { + const config = getDefaultConfig(); + config.credentials.id = 'user@example.com'; + config.credentials.password = 'password'; + config.phone = '01012345678'; + config.terms = { + provider: 'skt', + accepted: true, + accepted_at: '2026-03-30T00:00:00.000Z', + version: SKT_TERMS_VERSION, + urls: SKT_TERMS_URLS, + }; + + expect(validateRequiredFields(config)).toEqual([]); + }); +}); + +describe('loadConfig and saveConfig terms handling', () => { + it('should default missing terms in old configs safely', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skt-config-')); + const configPath = path.join(dir, 'config.yaml'); + fs.writeFileSync(configPath, [ + '_config_version: 3', + 'credentials:', + ' id: "user@example.com"', + ' password: "password"', + 'phone: "01012345678"', + ].join('\n'), 'utf8'); + + const config = loadConfig(configPath); + + expect(config.terms.accepted).toBe(false); + expect(config.terms.version).toBe(SKT_TERMS_VERSION); + expect(config.terms.urls).toEqual(SKT_TERMS_URLS); + }); + + it('should preserve accepted current terms when saving and loading', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skt-config-')); + const configPath = path.join(dir, 'config.yaml'); + const config = getDefaultConfig(); + config.terms = { + provider: 'skt', + accepted: true, + accepted_at: '2026-03-30T00:00:00.000Z', + version: SKT_TERMS_VERSION, + urls: SKT_TERMS_URLS, + }; + + saveConfig(config, configPath); + const loaded = loadConfig(configPath); + + expect(loaded.terms).toEqual(config.terms); + }); +}); + + +describe('terms migration', () => { + it('should record SKT terms acceptance when migrating v3 configs to v4', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skt-config-')); + const configPath = path.join(dir, 'config.yaml'); + const config = getDefaultConfig(); + config._config_version = 3; + config.credentials.id = 'user@example.com'; + config.credentials.password = 'password'; + config.phone = '01012345678'; + + vi.spyOn(inquirer, 'prompt').mockResolvedValue({ apply: true }); + + const migrated = await checkAndRunMigrations(config, configPath, { interactive: true }); + + expect(migrated._config_version).toBe(CURRENT_CONFIG_VERSION); + expect(migrated.terms).toMatchObject({ + provider: 'skt', + accepted: true, + version: SKT_TERMS_VERSION, + urls: SKT_TERMS_URLS, + }); + expect(new Date(migrated.terms.accepted_at).toISOString()).toBe(migrated.terms.accepted_at); + expect(loadConfig(configPath).terms.accepted).toBe(true); + }); +}); diff --git a/tests/db.test.ts b/tests/db.test.ts new file mode 100644 index 0000000..a65fdc6 --- /dev/null +++ b/tests/db.test.ts @@ -0,0 +1,89 @@ +/** + * db.ts 단위 테스트 + * - getTodayRecords(): UTC로 저장된 measured_at과 타임존 로컬 날짜 비교가 + * 올바르게 동작해야 함 (이슈 #5) + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { SpeedDatabase, SpeedRecord } from '../src/db'; + +function makeRecord(overrides: Partial): Omit { + return { + isp: 'skt', + measured_at: new Date().toISOString(), + download_mbps: 100, + upload_mbps: 100, + ping_ms: 10, + sla_result: 'fail', + complaint_filed: true, + complaint_result: 'success', + raw_data: '{}', + error: '', + ...overrides, + }; +} + +describe('SpeedDatabase.getTodayRecords (timezone-aware)', () => { + let tmpDir: string; + let db: SpeedDatabase; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dmsk-db-')); + db = new SpeedDatabase(path.join(tmpDir, 'test.db')); + }); + + afterEach(() => { + db.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('treats UTC record from KST early morning as today (issue #5)', () => { + // KST 2026-04-18 04:00 = UTC 2026-04-17 19:00 + const utcEarlyMorning = '2026-04-17T19:00:00.000Z'; + db.save(makeRecord({ measured_at: utcEarlyMorning })); + + // 같은 KST 날짜(2026-04-18)의 06:00 = UTC 2026-04-17 21:00 시점 기준으로 조회 + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T21:00:00.000Z')); + try { + const records = db.getTodayRecords('Asia/Seoul'); + expect(records).toHaveLength(1); + expect(records[0].measured_at).toBe(utcEarlyMorning); + } finally { + vi.useRealTimers(); + } + }); + + it('hasTodayComplaintSuccess returns true for KST early-morning success', () => { + db.save( + makeRecord({ + measured_at: '2026-04-17T19:00:00.000Z', // KST 2026-04-18 04:00 + complaint_result: 'success', + }) + ); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T21:00:00.000Z')); // KST 06:00 + try { + expect(db.hasTodayComplaintSuccess('Asia/Seoul')).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('excludes records from previous KST day', () => { + // KST 2026-04-17 23:00 = UTC 2026-04-17 14:00 (어제) + db.save(makeRecord({ measured_at: '2026-04-17T14:00:00.000Z' })); + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-17T21:00:00.000Z')); // KST 2026-04-18 06:00 + try { + const records = db.getTodayRecords('Asia/Seoul'); + expect(records).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c5d976c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e8857d1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + globals: true, + testTimeout: 10_000, + }, +});