From e6f4e1332dc67976c2e8a4730b78dc17af347fe7 Mon Sep 17 00:00:00 2001 From: vildanbina Date: Sun, 8 Feb 2026 22:48:47 +0100 Subject: [PATCH] feat: harden CLI for public release --- .github/workflows/prettier.yml | 20 + .github/workflows/publish.yml | 28 + .github/workflows/tests.yml | 25 + .prettierrc | 21 + README.md | 17 +- package-lock.json | 799 ++++++++++++++++++++++ package.json | 7 +- pnpm-lock.yaml | 1142 -------------------------------- src/api/client.test.ts | 69 +- src/api/client.ts | 95 ++- src/auth/oauth.test.ts | 111 +++- src/auth/oauth.ts | 107 ++- src/auth/store.test.ts | 97 +-- src/auth/store.ts | 11 +- src/commands/generate.test.ts | 73 +- src/commands/generate.ts | 71 +- src/commands/login.test.ts | 32 +- src/commands/login.ts | 12 +- src/commands/logout.test.ts | 12 +- src/commands/logout.ts | 4 +- src/commands/projects.test.ts | 17 +- src/commands/projects.ts | 6 +- src/config.ts | 5 +- src/index.ts | 14 +- src/utils/index.ts | 2 + src/utils/output.test.ts | 17 +- src/utils/output.ts | 5 +- src/utils/paths.test.ts | 40 +- src/utils/paths.ts | 19 +- 29 files changed, 1465 insertions(+), 1413 deletions(-) create mode 100644 .github/workflows/prettier.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .prettierrc delete mode 100644 pnpm-lock.yaml create mode 100644 src/utils/index.ts diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml new file mode 100644 index 0000000..36211e2 --- /dev/null +++ b/.github/workflows/prettier.yml @@ -0,0 +1,20 @@ +name: Prettier + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + prettier: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: npm ci + - run: npx prettier --check "src/**/*.{ts,js}" "*.{json,md}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b2489bf --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish to npm + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - run: npm ci + - run: npm run build + - run: npm run test + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fdeebce --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - run: npm ci + - run: npm run typecheck + - run: npm run test diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b3d3241 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,21 @@ +{ + "semi": true, + "singleQuote": true, + "singleAttributePerLine": false, + "printWidth": 150, + "tabWidth": 4, + "overrides": [ + { + "files": "**/*.yml", + "options": { + "tabWidth": 2 + } + }, + { + "files": "*.md", + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/README.md b/README.md index 28fe29f..d71a355 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ yavy generate my-org/my-project ### `yavy login` -Opens your browser to authenticate with your Yavy account. Credentials are stored in `~/.yavy/credentials.json`. +Opens your browser to authenticate with your Yavy account using OAuth (PKCE). Credentials are stored in `~/.yavy/credentials.json`. ### `yavy logout` @@ -43,24 +43,31 @@ Lists all projects you have access to across your organizations. ### `yavy generate ` -Generates a skill file from a project's indexed documentation. +Downloads a skill from a project's indexed documentation. | Flag | Description | | ----------------- | ----------------------------------------------------- | | `--global` | Save to global skills directory (`~/.claude/skills/`) | | `--output ` | Custom output path | -| `--force` | Force regeneration even if cached | +| `--force` | Overwrite existing skill files | | `--json` | Output as JSON | -By default, skills are saved to `.claude/skills//SKILL.md` in the current directory. +By default, skills are saved to `.claude/skills//` in the current directory. ## How It Works 1. Yavy indexes your documentation sources (websites, GitHub repos, Confluence, Notion) -2. The CLI calls the Yavy API to generate a skill using the indexed content +2. The CLI calls the Yavy API to download a skill using the indexed content 3. The skill file is saved locally for your AI coding tools to discover 4. AI coding assistants automatically activate the skill when working with relevant code +## Configuration + +| Environment Variable | Description | Default | +| -------------------- | ------------------------ | ------------------ | +| `YAVY_BASE_URL` | Override API base URL | `https://yavy.dev` | +| `YAVY_CLIENT_ID` | Override OAuth client ID | (built-in) | + ## Related - [Yavy Claude Code Plugin](https://github.com/yavydev/claude-code) — Claude Code plugin with interactive setup diff --git a/package-lock.json b/package-lock.json index 4a90054..3571b5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "chalk": "^5.3.0", "commander": "^13.1.0", + "fflate": "^0.8.2", "open": "^10.1.0", "ora": "^8.1.1" }, @@ -19,6 +20,8 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.4", + "prettier": "^3.7.0", "tsup": "^8.4.0", "typescript": "^5.7.0", "vitest": "^3.0.0" @@ -27,6 +30,80 @@ "node": ">=18" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -469,6 +546,59 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -508,6 +638,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -893,6 +1034,40 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -1033,6 +1208,19 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1050,6 +1238,42 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1173,6 +1397,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -1199,6 +1443,21 @@ "node": "^14.18.0 || >=16.10.0" } }, + "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", @@ -1267,6 +1526,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -1360,6 +1626,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1372,6 +1644,23 @@ "rollup": "^4.34.8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1399,6 +1688,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1414,6 +1742,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -1471,6 +1809,83 @@ "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1553,6 +1968,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1563,6 +1985,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -1575,6 +2025,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -1692,6 +2168,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1823,6 +2333,22 @@ } } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1920,6 +2446,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -2002,6 +2564,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "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==", + "dev": true, + "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/string-width-cjs/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -2017,6 +2625,30 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -2063,6 +2695,34 @@ "node": ">= 6" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2416,6 +3076,22 @@ } } }, + "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", @@ -2433,6 +3109,129 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/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==", + "dev": true, + "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/wrap-ansi-cjs/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/package.json b/package.json index c03a9a6..f38315b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "dev": "tsup --watch", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "format": "prettier --write \"src/**/*.{ts,js}\" \"*.{json,md}\"", + "format:check": "prettier --check \"src/**/*.{ts,js}\" \"*.{json,md}\"" }, "keywords": [ "yavy", @@ -36,13 +38,16 @@ "dependencies": { "chalk": "^5.3.0", "commander": "^13.1.0", + "fflate": "^0.8.2", "open": "^10.1.0", "ora": "^8.1.1" }, "devDependencies": { "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.4", "tsup": "^8.4.0", "typescript": "^5.7.0", + "prettier": "^3.7.0", "vitest": "^3.0.0" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 623dd28..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,1142 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - chalk: - specifier: ^5.3.0 - version: 5.6.2 - commander: - specifier: ^13.1.0 - version: 13.1.0 - open: - specifier: ^10.1.0 - version: 10.2.0 - ora: - specifier: ^8.1.1 - version: 8.2.0 - devDependencies: - '@types/node': - specifier: ^22.0.0 - version: 22.19.9 - tsup: - specifier: ^8.4.0 - version: 8.5.1(typescript@5.9.3) - typescript: - specifier: ^5.7.0 - version: 5.9.3 - -packages: - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@rollup/rollup-android-arm-eabi@4.57.1': - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.57.1': - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.57.1': - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.57.1': - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.57.1': - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.57.1': - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.57.1': - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.57.1': - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.57.1': - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.57.1': - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.57.1': - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.57.1': - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.57.1': - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.57.1': - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} - cpu: [x64] - os: [win32] - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/node@22.19.9': - resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - bundle-name@4.1.0: - resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} - engines: {node: '>=18'} - - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} - engines: {node: '>=18'} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - default-browser-id@5.0.1: - resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} - engines: {node: '>=18'} - - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} - engines: {node: '>=18'} - - define-lazy-prop@3.0.0: - resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} - engines: {node: '>=12'} - - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - - is-interactive@2.0.0: - resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} - engines: {node: '>=12'} - - is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - - is-unicode-supported@2.1.0: - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} - engines: {node: '>=18'} - - is-wsl@3.1.0: - resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} - engines: {node: '>=16'} - - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - log-symbols@6.0.0: - resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} - engines: {node: '>=18'} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - - open@10.2.0: - resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} - engines: {node: '>=18'} - - ora@8.2.0: - resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} - engines: {node: '>=18'} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - run-applescript@7.1.0: - resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} - engines: {node: '>=18'} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - - stdin-discarder@0.2.2: - resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} - engines: {node: '>=18'} - - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - wsl-utils@0.1.0: - resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} - engines: {node: '>=18'} - -snapshots: - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-x64@0.27.3': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@rollup/rollup-android-arm-eabi@4.57.1': - optional: true - - '@rollup/rollup-android-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.57.1': - optional: true - - '@rollup/rollup-darwin-x64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.57.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.57.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.57.1': - optional: true - - '@rollup/rollup-openbsd-x64@4.57.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.57.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.57.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.57.1': - optional: true - - '@types/estree@1.0.8': {} - - '@types/node@22.19.9': - dependencies: - undici-types: 6.21.0 - - acorn@8.15.0: {} - - ansi-regex@6.2.2: {} - - any-promise@1.3.0: {} - - bundle-name@4.1.0: - dependencies: - run-applescript: 7.1.0 - - bundle-require@5.1.0(esbuild@0.27.3): - dependencies: - esbuild: 0.27.3 - load-tsconfig: 0.2.5 - - cac@6.7.14: {} - - chalk@5.6.2: {} - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-spinners@2.9.2: {} - - commander@13.1.0: {} - - commander@4.1.1: {} - - confbox@0.1.8: {} - - consola@3.4.2: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - default-browser-id@5.0.1: {} - - default-browser@5.5.0: - dependencies: - bundle-name: 4.1.0 - default-browser-id: 5.0.1 - - define-lazy-prop@3.0.0: {} - - emoji-regex@10.6.0: {} - - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - fix-dts-default-cjs-exports@1.0.1: - dependencies: - magic-string: 0.30.21 - mlly: 1.8.0 - rollup: 4.57.1 - - fsevents@2.3.3: - optional: true - - get-east-asian-width@1.4.0: {} - - is-docker@3.0.0: {} - - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - - is-interactive@2.0.0: {} - - is-unicode-supported@1.3.0: {} - - is-unicode-supported@2.1.0: {} - - is-wsl@3.1.0: - dependencies: - is-inside-container: 1.0.0 - - joycon@3.1.1: {} - - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} - - log-symbols@6.0.0: - dependencies: - chalk: 5.6.2 - is-unicode-supported: 1.3.0 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - mimic-function@5.0.1: {} - - mlly@1.8.0: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - - ms@2.1.3: {} - - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - object-assign@4.1.1: {} - - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - - open@10.2.0: - dependencies: - default-browser: 5.5.0 - define-lazy-prop: 3.0.0 - is-inside-container: 1.0.0 - wsl-utils: 0.1.0 - - ora@8.2.0: - dependencies: - chalk: 5.6.2 - cli-cursor: 5.0.0 - cli-spinners: 2.9.2 - is-interactive: 2.0.0 - is-unicode-supported: 2.1.0 - log-symbols: 6.0.0 - stdin-discarder: 0.2.2 - string-width: 7.2.0 - strip-ansi: 7.1.2 - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - pirates@4.0.7: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - - postcss-load-config@6.0.1: - dependencies: - lilconfig: 3.1.3 - - readdirp@4.1.2: {} - - resolve-from@5.0.0: {} - - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - - rollup@4.57.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 - fsevents: 2.3.3 - - run-applescript@7.1.0: {} - - signal-exit@4.1.0: {} - - source-map@0.7.6: {} - - stdin-discarder@0.2.2: {} - - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 - - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 - - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - tinyexec@0.3.2: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - tree-kill@1.2.2: {} - - ts-interface-checker@0.1.13: {} - - tsup@8.5.1(typescript@5.9.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.3) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.3 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1 - resolve-from: 5.0.0 - rollup: 4.57.1 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - - typescript@5.9.3: {} - - ufo@1.6.3: {} - - undici-types@6.21.0: {} - - wsl-utils@0.1.0: - dependencies: - is-wsl: 3.1.0 diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 7e425de..4cd444f 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -1,17 +1,20 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createMockResponse } from '../__test__/helpers.js'; -import { YavyApiClient } from './client.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockResponse } from '../__test__/helpers'; +import { YavyApiClient } from './client'; -vi.mock('../auth/store.js', () => ({ +vi.mock('../auth/store', () => ({ getAccessToken: vi.fn(), })); -vi.mock('../config.js', () => ({ +vi.mock('../config', () => ({ YAVY_BASE_URL: 'https://test.yavy.dev', YAVY_CLIENT_ID: 'test-client-id', + YAVY_USER_AGENT: '@yavydev/cli', + REQUEST_TIMEOUT_MS: 30_000, + MAX_RETRIES: 3, })); -import { getAccessToken } from '../auth/store.js'; +import { getAccessToken } from '../auth/store'; beforeEach(() => { vi.clearAllMocks(); @@ -42,7 +45,7 @@ describe('listProjects', () => { vi.mocked(getAccessToken).mockResolvedValue('test-token'); }); - it('sends GET to /api/v1/projects with Bearer auth', async () => { + it('sends GET to /api/v1/projects with Bearer auth and User-Agent', async () => { const projects = [{ id: 1, name: 'Test Project' }]; vi.mocked(fetch).mockResolvedValue(createMockResponse({ data: projects })); @@ -55,6 +58,7 @@ describe('listProjects', () => { method: 'GET', headers: expect.objectContaining({ Authorization: 'Bearer test-token', + 'User-Agent': '@yavydev/cli', }), }), ); @@ -71,7 +75,7 @@ describe('downloadSkill', () => { client = await YavyApiClient.create(); }); - it('sends GET to correct download path', async () => { + it('sends GET to correct download path with Accept: application/zip', async () => { const mockBuffer = new ArrayBuffer(10); vi.mocked(fetch).mockResolvedValue({ ok: true, @@ -89,6 +93,7 @@ describe('downloadSkill', () => { headers: expect.objectContaining({ Authorization: 'Bearer test-token', Accept: 'application/zip', + 'User-Agent': '@yavydev/cli', }), }), ); @@ -157,10 +162,54 @@ describe('error handling', () => { it('throws generic message when error body is not JSON', async () => { vi.mocked(fetch).mockResolvedValue({ ok: false, - status: 502, + status: 400, json: () => Promise.reject(new Error('not json')), headers: new Headers(), } as Response); - await expect(client.listProjects()).rejects.toThrow('API request failed with status 502'); + await expect(client.listProjects()).rejects.toThrow('API request failed with status 400'); + }); +}); + +describe('retry behavior', () => { + let client: YavyApiClient; + + beforeEach(async () => { + vi.stubGlobal('fetch', vi.fn()); + vi.mocked(getAccessToken).mockResolvedValue('test-token'); + client = await YavyApiClient.create(); + }); + + it('retries on 503 and succeeds on second attempt', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(createMockResponse({}, 503)) + .mockResolvedValueOnce(createMockResponse({ data: [] })); + + const result = await client.listProjects(); + expect(result).toEqual([]); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('retries on network TypeError', async () => { + vi.mocked(fetch) + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce(createMockResponse({ data: [{ id: 1 }] })); + + const result = await client.listProjects(); + expect(result).toEqual([{ id: 1 }]); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('does not retry on 401', async () => { + vi.mocked(fetch).mockResolvedValue(createMockResponse({}, 401)); + + await expect(client.listProjects()).rejects.toThrow('Authentication expired'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not retry on 404', async () => { + vi.mocked(fetch).mockResolvedValue(createMockResponse({ error: 'Not found' }, 404)); + + await expect(client.listProjects()).rejects.toThrow('Not found'); + expect(fetch).toHaveBeenCalledTimes(1); }); }); diff --git a/src/api/client.ts b/src/api/client.ts index 9880c79..b613728 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,5 @@ -import { getAccessToken } from '../auth/store.js'; -import { YAVY_BASE_URL } from '../config.js'; +import { getAccessToken } from '../auth/store'; +import { MAX_RETRIES, REQUEST_TIMEOUT_MS, YAVY_BASE_URL, YAVY_USER_AGENT } from '../config'; export interface ApiProject { id: number; @@ -15,6 +15,19 @@ export interface ApiProject { has_indexed_content: boolean; } +const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); + +function isRetryable(error: unknown, status?: number): boolean { + if (status && RETRYABLE_STATUS_CODES.has(status)) return true; + if (error instanceof TypeError) return true; // fetch network errors + if (error instanceof DOMException && error.name === 'AbortError') return false; + return false; +} + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export class YavyApiClient { private token: string; @@ -30,30 +43,71 @@ export class YavyApiClient { return new YavyApiClient(token); } - private async request(method: string, path: string, body?: unknown): Promise { - const url = `${YAVY_BASE_URL}/api/v1${path}`; - const headers: Record = { + private baseHeaders(accept = 'application/json'): Record { + return { Authorization: `Bearer ${this.token}`, - Accept: 'application/json', + Accept: accept, + 'User-Agent': YAVY_USER_AGENT, }; + } + + private async fetchWithRetry(url: string, init: RequestInit): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 0) { + const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000); + const jitter = Math.random() * delay * 0.1; + await sleep(delay + jitter); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + + if (response.ok || response.status === 401 || !isRetryable(null, response.status)) { + return response; + } + + lastError = new Error(`HTTP ${response.status}`); + } catch (err) { + if (!isRetryable(err)) throw err; + lastError = err; + } finally { + clearTimeout(timeoutId); + } + } + + throw lastError; + } + + private async handleErrorResponse(response: Response): Promise { + if (response.status === 401) { + throw new Error('Authentication expired. Run `yavy login` to re-authenticate.'); + } + + const errorData = (await response.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorData.error ?? `API request failed with status ${response.status}`); + } + + private async request(method: string, path: string, body?: unknown): Promise { + const url = `${YAVY_BASE_URL}/api/v1${path}`; + const headers: Record = { ...this.baseHeaders() }; if (body) { headers['Content-Type'] = 'application/json'; } - const response = await fetch(url, { + const response = await this.fetchWithRetry(url, { method, headers, body: body ? JSON.stringify(body) : undefined, }); - if (response.status === 401) { - throw new Error('Authentication expired. Run `yavy login` to re-authenticate.'); - } - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { error?: string }; - throw new Error(errorData.error ?? `API request failed with status ${response.status}`); + await this.handleErrorResponse(response); } return response.json() as Promise; @@ -66,21 +120,14 @@ export class YavyApiClient { async downloadSkill(orgSlug: string, projectSlug: string): Promise { const url = `${YAVY_BASE_URL}/api/v1/${orgSlug}/${projectSlug}/skill/download`; - const response = await fetch(url, { + + const response = await this.fetchWithRetry(url, { method: 'GET', - headers: { - Authorization: `Bearer ${this.token}`, - Accept: 'application/zip', - }, + headers: this.baseHeaders('application/zip'), }); - if (response.status === 401) { - throw new Error('Authentication expired. Run `yavy login` to re-authenticate.'); - } - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { error?: string }; - throw new Error(errorData.error ?? `API request failed with status ${response.status}`); + await this.handleErrorResponse(response); } return response.arrayBuffer(); diff --git a/src/auth/oauth.test.ts b/src/auth/oauth.test.ts index 0162a3b..60188a5 100644 --- a/src/auth/oauth.test.ts +++ b/src/auth/oauth.test.ts @@ -1,49 +1,49 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { createMockResponse } from '../__test__/helpers.js'; +import type { AddressInfo } from 'node:net'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockResponse } from '../__test__/helpers'; vi.mock('open', () => ({ default: vi.fn(), })); -vi.mock('./store.js', () => ({ +vi.mock('./store', () => ({ saveCredentials: vi.fn(), })); -vi.mock('../config.js', () => ({ +vi.mock('../config', () => ({ YAVY_BASE_URL: 'https://test.yavy.dev', YAVY_CLIENT_ID: 'test-client-id', })); import openBrowser from 'open'; -import { saveCredentials } from './store.js'; +import { saveCredentials } from './store'; -// We'll capture the request handler and simulate requests instead of using real HTTP let requestHandler: (req: IncomingMessage, res: ServerResponse) => void; -let listenCallback: () => void; let serverClosed: boolean; -let closeCallback: (() => void) | undefined; +let mockPort: number; vi.mock('node:http', () => ({ createServer: vi.fn((handler: (req: IncomingMessage, res: ServerResponse) => void) => { requestHandler = handler; serverClosed = false; - return { - listen: vi.fn((_port: number, cb: () => void) => { - listenCallback = cb; - // Call listen callback asynchronously to match real behavior + const server = { + listen: vi.fn((port: number, cb: () => void) => { + mockPort = port === 0 ? 44321 : port; setTimeout(cb, 0); }), close: vi.fn((cb?: () => void) => { serverClosed = true; - closeCallback = cb; cb?.(); }), + address: vi.fn(() => ({ port: mockPort }) as AddressInfo), + once: vi.fn(), + removeListener: vi.fn(), }; + return server; }), })); -// Simulate an incoming request to the handler function simulateRequest(urlPath: string): { statusCode: number; body: string; headers: Record } { const result = { statusCode: 200, body: '', headers: {} as Record }; const mockReq = { url: urlPath } as IncomingMessage; @@ -61,17 +61,14 @@ function simulateRequest(urlPath: string): { statusCode: number; body: string; h return result; } -// Need dynamic import after mocks are set up -let performOAuthLogin: typeof import('./oauth.js').performOAuthLogin; +let performOAuthLogin: typeof import('./oauth').performOAuthLogin; beforeEach(async () => { vi.clearAllMocks(); - // Reset shared mock state requestHandler = undefined as any; - listenCallback = undefined as any; serverClosed = false; - closeCallback = undefined; - const mod = await import('./oauth.js'); + mockPort = 9876; + const mod = await import('./oauth'); performOAuthLogin = mod.performOAuthLogin; }); @@ -80,7 +77,7 @@ describe('performOAuthLogin', () => { vi.stubGlobal('fetch', vi.fn()); }); - it('opens browser with correct authorization URL containing PKCE', async () => { + it('opens browser with correct authorization URL containing PKCE and state', async () => { vi.mocked(fetch).mockResolvedValue( createMockResponse({ access_token: 'tok', @@ -91,7 +88,6 @@ describe('performOAuthLogin', () => { ); const loginPromise = performOAuthLogin(); - // Let listen callback fire await new Promise((r) => setTimeout(r, 50)); expect(openBrowser).toHaveBeenCalledOnce(); @@ -102,11 +98,11 @@ describe('performOAuthLogin', () => { expect(url.searchParams.get('response_type')).toBe('code'); expect(url.searchParams.get('code_challenge_method')).toBe('S256'); expect(url.searchParams.get('code_challenge')).toBeTruthy(); - expect(url.searchParams.get('redirect_uri')).toBe('http://localhost:9876/callback'); + expect(url.searchParams.get('state')).toBeTruthy(); + expect(url.searchParams.has('scope')).toBe(false); - // Simulate callback with auth code - simulateRequest('/callback?code=auth-code-123'); - // Let the async token exchange resolve + const state = url.searchParams.get('state'); + simulateRequest(`/callback?code=auth-code-123&state=${state}`); await new Promise((r) => setTimeout(r, 50)); const result = await loginPromise; expect(result).toBe(true); @@ -125,7 +121,10 @@ describe('performOAuthLogin', () => { const loginPromise = performOAuthLogin(); await new Promise((r) => setTimeout(r, 50)); - simulateRequest('/callback?code=test-code'); + const url = new URL(vi.mocked(openBrowser).mock.calls[0][0] as string); + const state = url.searchParams.get('state'); + + simulateRequest(`/callback?code=test-code&state=${state}`); await new Promise((r) => setTimeout(r, 50)); await loginPromise; @@ -157,7 +156,10 @@ describe('performOAuthLogin', () => { const loginPromise = performOAuthLogin(); await new Promise((r) => setTimeout(r, 50)); - simulateRequest('/callback?code=test-code'); + const url = new URL(vi.mocked(openBrowser).mock.calls[0][0] as string); + const state = url.searchParams.get('state'); + + simulateRequest(`/callback?code=test-code&state=${state}`); await new Promise((r) => setTimeout(r, 50)); await loginPromise; @@ -187,20 +189,35 @@ describe('performOAuthLogin', () => { expect(result).toBe(false); }); + it('returns false when state parameter does not match (CSRF protection)', async () => { + const loginPromise = performOAuthLogin(); + await new Promise((r) => setTimeout(r, 50)); + + const response = simulateRequest('/callback?code=test-code&state=wrong-state'); + expect(response.body).toContain('Invalid state parameter'); + + const result = await loginPromise; + expect(result).toBe(false); + }); + it('returns false when token exchange returns non-ok response', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); vi.mocked(fetch).mockResolvedValue(createMockResponse({}, 400)); const loginPromise = performOAuthLogin(); await new Promise((r) => setTimeout(r, 50)); - simulateRequest('/callback?code=bad-code'); + const url = new URL(vi.mocked(openBrowser).mock.calls[0][0] as string); + const state = url.searchParams.get('state'); + + simulateRequest(`/callback?code=bad-code&state=${state}`); await new Promise((r) => setTimeout(r, 50)); const result = await loginPromise; expect(result).toBe(false); }); it('returns 404 for non-callback paths', async () => { + vi.mocked(fetch).mockResolvedValue(createMockResponse({ access_token: 'tok', token_type: 'Bearer' })); + const loginPromise = performOAuthLogin(); await new Promise((r) => setTimeout(r, 50)); @@ -208,11 +225,9 @@ describe('performOAuthLogin', () => { expect(response.statusCode).toBe(404); expect(response.body).toBe('Not found'); - // Clean up: trigger callback to resolve the promise - vi.mocked(fetch).mockResolvedValue( - createMockResponse({ access_token: 'tok', token_type: 'Bearer' }), - ); - simulateRequest('/callback?code=cleanup'); + const url = new URL(vi.mocked(openBrowser).mock.calls[0][0] as string); + const state = url.searchParams.get('state'); + simulateRequest(`/callback?code=cleanup&state=${state}`); await new Promise((r) => setTimeout(r, 50)); await loginPromise; }); @@ -229,4 +244,30 @@ describe('performOAuthLogin', () => { vi.useRealTimers(); }); + + it('includes redirect_uri with correct port in token exchange', async () => { + vi.mocked(fetch).mockResolvedValue( + createMockResponse({ + access_token: 'tok', + token_type: 'Bearer', + }), + ); + + const loginPromise = performOAuthLogin(); + await new Promise((r) => setTimeout(r, 50)); + + const url = new URL(vi.mocked(openBrowser).mock.calls[0][0] as string); + const state = url.searchParams.get('state'); + const redirectUri = url.searchParams.get('redirect_uri'); + + expect(redirectUri).toContain('localhost'); + expect(redirectUri).toContain('/callback'); + + simulateRequest(`/callback?code=test&state=${state}`); + await new Promise((r) => setTimeout(r, 50)); + await loginPromise; + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0][1]!.body as string); + expect(body.redirect_uri).toBe(redirectUri); + }); }); diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 6474316..7d4c4e8 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -1,10 +1,12 @@ import { createHash, randomBytes } from 'node:crypto'; -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { URL } from 'node:url'; import open from 'open'; -import { saveCredentials } from './store.js'; -import { YAVY_BASE_URL, YAVY_CLIENT_ID } from '../config.js'; -const CALLBACK_PORT = 9876; +import { YAVY_BASE_URL, YAVY_CLIENT_ID } from '../config'; +import { saveCredentials } from './store'; + +const PREFERRED_PORTS = [9876, 9877, 9878, 0]; // 0 = OS-assigned fallback const CALLBACK_PATH = '/callback'; interface TokenResponse { @@ -22,13 +24,52 @@ function generateCodeChallenge(verifier: string): string { return createHash('sha256').update(verifier).digest('base64url'); } +function generateState(): string { + return randomBytes(16).toString('base64url'); +} + +function tryListen(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, () => { + server.removeListener('error', reject); + const addr = server.address() as AddressInfo; + resolve(addr.port); + }); + }); +} + +async function listenOnAvailablePort(server: Server): Promise { + for (const port of PREFERRED_PORTS) { + try { + return await tryListen(server, port); + } catch (err: unknown) { + if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EADDRINUSE') { + continue; + } + throw err; + } + } + throw new Error('Could not find an available port for OAuth callback'); +} + export async function performOAuthLogin(): Promise { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); + + return new Promise((resolve, reject) => { + let settled = false; + const settle = (value: boolean) => { + if (!settled) { + settled = true; + resolve(value); + } + }; - return new Promise((resolve) => { const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - const url = new URL(req.url ?? '/', `http://localhost:${CALLBACK_PORT}`); + const actualPort = (server.address() as AddressInfo).port; + const url = new URL(req.url ?? '/', `http://localhost:${actualPort}`); if (url.pathname !== CALLBACK_PATH) { res.writeHead(404); @@ -37,25 +78,34 @@ export async function performOAuthLogin(): Promise { } const code = url.searchParams.get('code'); + const returnedState = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error || !code) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Authentication failed

You can close this window.

'); server.close(); - resolve(false); + settle(false); + return; + } + + if (returnedState !== state) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authentication failed

Invalid state parameter. Possible CSRF attack.

'); + server.close(); + settle(false); return; } try { - // Exchange code for token + const redirectUri = `http://localhost:${actualPort}${CALLBACK_PATH}`; const tokenResponse = await fetch(`${YAVY_BASE_URL}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', code, - redirect_uri: `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`, + redirect_uri: redirectUri, client_id: YAVY_CLIENT_ID, code_verifier: codeVerifier, }), @@ -76,33 +126,40 @@ export async function performOAuthLogin(): Promise { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Logged in to Yavy!

You can close this window and return to your terminal.

'); server.close(); - resolve(true); - } catch (err) { - console.error('Token exchange failed:', err instanceof Error ? err.message : err); + settle(true); + } catch { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Authentication failed

Something went wrong. Please try again.

'); server.close(); - resolve(false); + settle(false); } }); - server.listen(CALLBACK_PORT, () => { - const authUrl = new URL('/oauth/authorize', YAVY_BASE_URL); - authUrl.searchParams.set('client_id', YAVY_CLIENT_ID); - authUrl.searchParams.set('redirect_uri', `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`); - authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('scope', ''); - authUrl.searchParams.set('code_challenge', codeChallenge); - authUrl.searchParams.set('code_challenge_method', 'S256'); - - open(authUrl.toString()); - }); + listenOnAvailablePort(server) + .then((actualPort) => { + const authUrl = new URL('/oauth/authorize', YAVY_BASE_URL); + authUrl.searchParams.set('client_id', YAVY_CLIENT_ID); + authUrl.searchParams.set('redirect_uri', `http://localhost:${actualPort}${CALLBACK_PATH}`); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + open(authUrl.toString()); + }) + .catch((err) => { + server.close(); + if (!settled) { + settled = true; + reject(err); + } + }); // Timeout after 5 minutes const timeout = setTimeout( () => { server.close(); - resolve(false); + settle(false); }, 5 * 60 * 1000, ); diff --git a/src/auth/store.test.ts b/src/auth/store.test.ts index d4914e5..aac85a3 100644 --- a/src/auth/store.test.ts +++ b/src/auth/store.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; -import { createMockResponse } from '../__test__/helpers.js'; -import { loadCredentials, saveCredentials, clearCredentials, getAccessToken, type Credentials } from './store.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockResponse } from '../__test__/helpers'; +import { clearCredentials, getAccessToken, isExpired, loadCredentials, saveCredentials, type Credentials } from './store'; vi.mock('node:fs', () => ({ existsSync: vi.fn(), @@ -15,7 +15,7 @@ vi.mock('node:os', () => ({ homedir: vi.fn(() => '/mock-home'), })); -vi.mock('../config.js', () => ({ +vi.mock('../config', () => ({ YAVY_BASE_URL: 'https://test.yavy.dev', YAVY_CLIENT_ID: 'test-client-id', })); @@ -45,27 +45,15 @@ describe('loadCredentials', () => { }); describe('saveCredentials', () => { - it('creates ~/.yavy directory if it does not exist', () => { - vi.mocked(existsSync).mockReturnValue(false); + it('creates ~/.yavy directory with recursive', () => { saveCredentials({ access_token: 'tok' }); expect(mkdirSync).toHaveBeenCalledWith('/mock-home/.yavy', { recursive: true }); }); it('writes JSON with mode 0o600', () => { - vi.mocked(existsSync).mockReturnValue(true); const creds: Credentials = { access_token: 'tok' }; saveCredentials(creds); - expect(writeFileSync).toHaveBeenCalledWith( - '/mock-home/.yavy/credentials.json', - JSON.stringify(creds, null, 2), - { mode: 0o600 }, - ); - }); - - it('skips mkdir when directory already exists', () => { - vi.mocked(existsSync).mockReturnValue(true); - saveCredentials({ access_token: 'tok' }); - expect(mkdirSync).not.toHaveBeenCalled(); + expect(writeFileSync).toHaveBeenCalledWith('/mock-home/.yavy/credentials.json', JSON.stringify(creds, null, 2), { mode: 0o600 }); }); }); @@ -83,6 +71,27 @@ describe('clearCredentials', () => { }); }); +describe('isExpired', () => { + it('returns false when no expires_at field', () => { + expect(isExpired({ access_token: 'tok' })).toBe(false); + }); + + it('returns false when token is fresh (well before expiry)', () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour from now + expect(isExpired({ access_token: 'tok', expires_at: future })).toBe(false); + }); + + it('returns true when token is within 5 min buffer of expiry', () => { + const nearFuture = new Date(Date.now() + 3 * 60 * 1000).toISOString(); // 3 min from now + expect(isExpired({ access_token: 'tok', expires_at: nearFuture })).toBe(true); + }); + + it('returns true when token is already past expiry', () => { + const past = new Date(Date.now() - 3600 * 1000).toISOString(); + expect(isExpired({ access_token: 'tok', expires_at: past })).toBe(true); + }); +}); + describe('getAccessToken', () => { beforeEach(() => { vi.clearAllMocks(); @@ -97,59 +106,53 @@ describe('getAccessToken', () => { it('returns access_token when not expired', async () => { const future = new Date(Date.now() + 3600 * 1000).toISOString(); vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue( - JSON.stringify({ access_token: 'valid-tok', expires_at: future }), - ); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ access_token: 'valid-tok', expires_at: future })); expect(await getAccessToken()).toBe('valid-tok'); }); it('returns access_token when no expires_at field', async () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue( - JSON.stringify({ access_token: 'no-expiry-tok' }), - ); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ access_token: 'no-expiry-tok' })); expect(await getAccessToken()).toBe('no-expiry-tok'); }); it('returns null when expired with no refresh_token', async () => { const past = new Date(Date.now() - 3600 * 1000).toISOString(); vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue( - JSON.stringify({ access_token: 'expired-tok', expires_at: past }), - ); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ access_token: 'expired-tok', expires_at: past })); expect(await getAccessToken()).toBeNull(); }); it('refreshes token when expired with refresh_token', async () => { const past = new Date(Date.now() - 3600 * 1000).toISOString(); vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue( - JSON.stringify({ access_token: 'old', refresh_token: 'ref-tok', expires_at: past }), - ); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ access_token: 'old', refresh_token: 'ref-tok', expires_at: past })); - vi.mocked(fetch).mockResolvedValue( - createMockResponse({ access_token: 'new-tok', refresh_token: 'new-ref', expires_in: 3600 }), - ); + vi.mocked(fetch).mockResolvedValue(createMockResponse({ access_token: 'new-tok', refresh_token: 'new-ref', expires_in: 3600 })); const token = await getAccessToken(); expect(token).toBe('new-tok'); - expect(fetch).toHaveBeenCalledWith( - 'https://test.yavy.dev/oauth/token', - expect.objectContaining({ method: 'POST' }), - ); - expect(writeFileSync).toHaveBeenCalledWith( - '/mock-home/.yavy/credentials.json', - expect.stringContaining('"access_token": "new-tok"'), - { mode: 0o600 }, - ); + expect(fetch).toHaveBeenCalledWith('https://test.yavy.dev/oauth/token', expect.objectContaining({ method: 'POST' })); + expect(writeFileSync).toHaveBeenCalledWith('/mock-home/.yavy/credentials.json', expect.stringContaining('"access_token": "new-tok"'), { + mode: 0o600, + }); + }); + + it('proactively refreshes when within 5 min buffer', async () => { + const nearExpiry = new Date(Date.now() + 2 * 60 * 1000).toISOString(); // 2 min left + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ access_token: 'almost-expired', refresh_token: 'ref-tok', expires_at: nearExpiry })); + + vi.mocked(fetch).mockResolvedValue(createMockResponse({ access_token: 'refreshed-tok', refresh_token: 'new-ref', expires_in: 3600 })); + + const token = await getAccessToken(); + expect(token).toBe('refreshed-tok'); }); it('returns null when refresh fails with non-ok response', async () => { const past = new Date(Date.now() - 3600 * 1000).toISOString(); vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue( - JSON.stringify({ access_token: 'old', refresh_token: 'ref-tok', expires_at: past }), - ); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ access_token: 'old', refresh_token: 'ref-tok', expires_at: past })); vi.mocked(fetch).mockResolvedValue(createMockResponse({}, 401)); expect(await getAccessToken()).toBeNull(); @@ -158,9 +161,7 @@ describe('getAccessToken', () => { it('returns null when refresh throws network error', async () => { const past = new Date(Date.now() - 3600 * 1000).toISOString(); vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue( - JSON.stringify({ access_token: 'old', refresh_token: 'ref-tok', expires_at: past }), - ); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ access_token: 'old', refresh_token: 'ref-tok', expires_at: past })); vi.mocked(fetch).mockRejectedValue(new Error('Network error')); expect(await getAccessToken()).toBeNull(); diff --git a/src/auth/store.ts b/src/auth/store.ts index ba554d3..6b09bcc 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -1,7 +1,9 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { YAVY_BASE_URL, YAVY_CLIENT_ID } from '../config.js'; +import { YAVY_BASE_URL, YAVY_CLIENT_ID } from '../config'; + +const REFRESH_BUFFER_MS = 5 * 60 * 1000; // Refresh 5 minutes before expiry export interface Credentials { access_token: string; @@ -25,7 +27,7 @@ export function loadCredentials(): Credentials | null { export function saveCredentials(creds: Credentials): void { const dir = join(homedir(), '.yavy'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true }); writeFileSync(credentialsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 }); } @@ -34,8 +36,9 @@ export function clearCredentials(): void { if (existsSync(path)) unlinkSync(path); } -function isExpired(creds: Credentials): boolean { - return !!creds.expires_at && new Date(creds.expires_at) <= new Date(); +export function isExpired(creds: Credentials): boolean { + if (!creds.expires_at) return false; + return new Date(creds.expires_at).getTime() - Date.now() <= REFRESH_BUFFER_MS; } async function refreshToken(token: string): Promise { diff --git a/src/commands/generate.test.ts b/src/commands/generate.test.ts index b77b413..48b78b7 100644 --- a/src/commands/generate.test.ts +++ b/src/commands/generate.test.ts @@ -1,20 +1,22 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { generateCommand } from './generate.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateCommand } from './generate'; -vi.mock('../api/client.js', () => ({ +vi.mock('../api/client', () => ({ YavyApiClient: { create: vi.fn(), }, })); -vi.mock('../utils/paths.js', () => ({ +vi.mock('../utils/paths', () => ({ getSkillOutputDir: vi.fn(() => '/mock/output/my-project'), ensureDir: vi.fn(), + isPathSafe: vi.fn(() => true), })); -vi.mock('../utils/output.js', () => ({ +vi.mock('../utils/output', () => ({ error: vi.fn(), success: vi.fn(), + warn: vi.fn(), })); vi.mock('node:fs', () => ({ @@ -22,12 +24,15 @@ vi.mock('node:fs', () => ({ readFileSync: vi.fn(() => Buffer.from('')), existsSync: vi.fn(() => false), readdirSync: vi.fn(() => []), - rmSync: vi.fn(), mkdirSync: vi.fn(), })); -vi.mock('node:child_process', () => ({ - execFileSync: vi.fn(), +vi.mock('fflate', () => ({ + unzipSync: vi.fn(() => ({ + 'my-project/SKILL.md': new Uint8Array([72, 101, 108, 108, 111]), + 'my-project/references/doc.md': new Uint8Array([68, 111, 99]), + 'my-project/': new Uint8Array(0), // directory entry + })), })); vi.mock('chalk', () => ({ @@ -45,9 +50,10 @@ vi.mock('ora', () => ({ })), })); -import { YavyApiClient } from '../api/client.js'; -import { getSkillOutputDir } from '../utils/paths.js'; -import { error, success } from '../utils/output.js'; +import { existsSync } from 'node:fs'; +import { YavyApiClient } from '../api/client'; +import { error, warn } from '../utils/output'; +import { getSkillOutputDir, isPathSafe } from '../utils/paths'; function createMockClient() { return { @@ -123,9 +129,46 @@ describe('generateCommand', () => { await run(['my-org/my-project', '--global']); - expect(getSkillOutputDir).toHaveBeenCalledWith( - 'my-project', - expect.objectContaining({ global: true }), - ); + expect(getSkillOutputDir).toHaveBeenCalledWith('my-project', expect.objectContaining({ global: true })); + }); + + it('warns and exits when skill exists without --force', async () => { + vi.mocked(existsSync).mockReturnValue(true); + + await run(['my-org/my-project']); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('already exist')); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('overwrites existing skill when --force is used', async () => { + vi.mocked(existsSync).mockReturnValue(true); + const mockClient = createMockClient(); + vi.mocked(YavyApiClient.create).mockResolvedValue(mockClient as unknown as YavyApiClient); + + await run(['my-org/my-project', '--force']); + + expect(mockClient.downloadSkill).toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('throws on unsafe zip paths (zip-slip protection)', async () => { + vi.mocked(isPathSafe).mockReturnValue(false); + const mockClient = createMockClient(); + vi.mocked(YavyApiClient.create).mockResolvedValue(mockClient as unknown as YavyApiClient); + + await run(['my-org/my-project']); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('unsafe path')); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('passes --output flag to getSkillOutputDir', async () => { + const mockClient = createMockClient(); + vi.mocked(YavyApiClient.create).mockResolvedValue(mockClient as unknown as YavyApiClient); + + await run(['my-org/my-project', '--output', '/custom/path']); + + expect(getSkillOutputDir).toHaveBeenCalledWith('my-project', expect.objectContaining({ output: '/custom/path' })); }); }); diff --git a/src/commands/generate.ts b/src/commands/generate.ts index d9e4edb..e1f6c07 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -1,13 +1,11 @@ import chalk from 'chalk'; import { Command } from 'commander'; -import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { execFileSync } from 'node:child_process'; +import { unzipSync } from 'fflate'; +import { existsSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, normalize } from 'node:path'; import ora from 'ora'; -import { YavyApiClient } from '../api/client.js'; -import { error, success } from '../utils/output.js'; -import { ensureDir, getSkillOutputDir } from '../utils/paths.js'; +import { YavyApiClient } from '../api/client'; +import { ensureDir, error, getSkillOutputDir, isPathSafe, success, warn } from '../utils'; export function generateCommand(): Command { return new Command('generate') @@ -15,8 +13,9 @@ export function generateCommand(): Command { .argument('', 'Organization and project slug (e.g., my-org/my-project)') .option('--global', 'Save to global skills directory (~/.claude/skills/)') .option('--output ', 'Custom output directory') + .option('--force', 'Overwrite existing skill files') .option('--json', 'Output as JSON') - .action(async (slug: string, options: { global?: boolean; output?: string; json?: boolean }) => { + .action(async (slug: string, options: { global?: boolean; output?: string; force?: boolean; json?: boolean }) => { const parts = slug.split('/'); if (parts.length !== 2) { error('Invalid slug format. Use: org-slug/project-slug'); @@ -24,14 +23,20 @@ export function generateCommand(): Command { } const [orgSlug, projectSlug] = parts; + const outputDir = getSkillOutputDir(projectSlug, options); + + if (!options.force && existsSync(join(outputDir, 'SKILL.md'))) { + warn(`Skill files already exist at ${outputDir}. Use --force to overwrite.`); + process.exit(1); + } + const spinner = options.json ? null : ora(`Downloading skill for ${chalk.bold(slug)}...`).start(); try { const client = await YavyApiClient.create(); const zipBuffer = await client.downloadSkill(orgSlug, projectSlug); - const outputDir = getSkillOutputDir(projectSlug, options); - extractZip(Buffer.from(zipBuffer), outputDir, projectSlug); + extractZip(new Uint8Array(zipBuffer), outputDir, projectSlug); spinner?.stop(); @@ -65,42 +70,28 @@ export function generateCommand(): Command { } /** - * Extract a zip buffer to the output directory. + * Extract a zip buffer to the output directory using fflate (pure JS, cross-platform). * Strips the top-level project-slug prefix from zip entries. + * Validates all paths to prevent zip-slip attacks. */ -function extractZip(zipBuffer: Buffer, outputDir: string, projectSlug: string): void { - const tmpZip = join(tmpdir(), `yavy-skill-${Date.now()}.zip`); - const tmpExtract = join(tmpdir(), `yavy-extract-${Date.now()}`); - - try { - writeFileSync(tmpZip, zipBuffer); - ensureDir(tmpExtract); +function extractZip(zipData: Uint8Array, outputDir: string, projectSlug: string): void { + const files = unzipSync(zipData); + const prefix = `${projectSlug}/`; - // execFileSync is safe from shell injection (no shell invoked) - execFileSync('unzip', ['-o', tmpZip, '-d', tmpExtract], { stdio: 'pipe' }); + ensureDir(outputDir); - const prefixDir = join(tmpExtract, projectSlug); - const sourceDir = existsSync(prefixDir) ? prefixDir : tmpExtract; + for (const [rawPath, data] of Object.entries(files)) { + if (rawPath.endsWith('/')) continue; - ensureDir(outputDir); - copyDirRecursive(sourceDir, outputDir); - } finally { - rmSync(tmpZip, { force: true }); - rmSync(tmpExtract, { recursive: true, force: true }); - } -} - -function copyDirRecursive(src: string, dest: string): void { - ensureDir(dest); + const relativePath = rawPath.startsWith(prefix) ? rawPath.slice(prefix.length) : rawPath; + const normalizedPath = normalize(relativePath); - for (const entry of readdirSync(src, { withFileTypes: true })) { - const srcPath = join(src, entry.name); - const destPath = join(dest, entry.name); - - if (entry.isDirectory()) { - copyDirRecursive(srcPath, destPath); - } else { - writeFileSync(destPath, readFileSync(srcPath)); + if (!isPathSafe(normalizedPath, outputDir)) { + throw new Error(`Zip contains unsafe path: ${rawPath}`); } + + const destPath = join(outputDir, normalizedPath); + ensureDir(dirname(destPath)); + writeFileSync(destPath, data); } } diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 09774e3..7ed8ee6 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -1,18 +1,20 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { loginCommand } from './login.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loginCommand } from './login'; -vi.mock('../auth/oauth.js', () => ({ +vi.mock('../auth/oauth', () => ({ performOAuthLogin: vi.fn(), })); -vi.mock('../auth/store.js', () => ({ +vi.mock('../auth/store', () => ({ loadCredentials: vi.fn(), + isExpired: vi.fn(), })); -vi.mock('../utils/output.js', () => ({ +vi.mock('../utils/output', () => ({ error: vi.fn(), info: vi.fn(), success: vi.fn(), + warn: vi.fn(), })); vi.mock('ora', () => ({ @@ -23,9 +25,9 @@ vi.mock('ora', () => ({ })), })); -import { performOAuthLogin } from '../auth/oauth.js'; -import { loadCredentials } from '../auth/store.js'; -import { info, success, error } from '../utils/output.js'; +import { performOAuthLogin } from '../auth/oauth'; +import { isExpired, loadCredentials } from '../auth/store'; +import { error, info, success, warn } from '../utils/output'; async function run() { const cmd = loginCommand(); @@ -44,8 +46,9 @@ describe('loginCommand', () => { vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); }); - it('skips OAuth when already logged in', async () => { + it('skips OAuth when already logged in with valid token', async () => { vi.mocked(loadCredentials).mockReturnValue({ access_token: 'existing-tok' }); + vi.mocked(isExpired).mockReturnValue(false); await run(); @@ -53,6 +56,17 @@ describe('loginCommand', () => { expect(performOAuthLogin).not.toHaveBeenCalled(); }); + it('shows warning and re-authenticates when token is expired', async () => { + vi.mocked(loadCredentials).mockReturnValue({ access_token: 'old-tok', expires_at: '2020-01-01' }); + vi.mocked(isExpired).mockReturnValue(true); + vi.mocked(performOAuthLogin).mockResolvedValue(true); + + await run(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('expired')); + expect(performOAuthLogin).toHaveBeenCalledOnce(); + }); + it('calls performOAuthLogin when not logged in', async () => { vi.mocked(loadCredentials).mockReturnValue(null); vi.mocked(performOAuthLogin).mockResolvedValue(true); diff --git a/src/commands/login.ts b/src/commands/login.ts index 347c577..ee7e217 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,17 +1,21 @@ import { Command } from 'commander'; import ora from 'ora'; -import { performOAuthLogin } from '../auth/oauth.js'; -import { loadCredentials } from '../auth/store.js'; -import { error, info, success } from '../utils/output.js'; +import { performOAuthLogin } from '../auth/oauth'; +import { isExpired, loadCredentials } from '../auth/store'; +import { error, info, success, warn } from '../utils'; export function loginCommand(): Command { return new Command('login').description('Log in to your Yavy account').action(async () => { const existing = loadCredentials(); - if (existing?.access_token) { + if (existing?.access_token && !isExpired(existing)) { info('You are already logged in. Use `yavy logout` first to switch accounts.'); return; } + if (existing && isExpired(existing)) { + warn('Your session has expired. Re-authenticating...'); + } + const spinner = ora('Opening browser for authentication...').start(); try { diff --git a/src/commands/logout.test.ts b/src/commands/logout.test.ts index 77d2f7c..2dd65c0 100644 --- a/src/commands/logout.test.ts +++ b/src/commands/logout.test.ts @@ -1,18 +1,18 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { logoutCommand } from './logout.js'; +import { describe, expect, it, vi } from 'vitest'; +import { logoutCommand } from './logout'; -vi.mock('../auth/store.js', () => ({ +vi.mock('../auth/store', () => ({ loadCredentials: vi.fn(), clearCredentials: vi.fn(), })); -vi.mock('../utils/output.js', () => ({ +vi.mock('../utils/output', () => ({ info: vi.fn(), success: vi.fn(), })); -import { loadCredentials, clearCredentials } from '../auth/store.js'; -import { info, success } from '../utils/output.js'; +import { clearCredentials, loadCredentials } from '../auth/store'; +import { info, success } from '../utils/output'; function run() { const cmd = logoutCommand(); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 707a065..1c7055d 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; -import { clearCredentials, loadCredentials } from '../auth/store.js'; -import { info, success } from '../utils/output.js'; +import { clearCredentials, loadCredentials } from '../auth/store'; +import { info, success } from '../utils'; export function logoutCommand(): Command { return new Command('logout').description('Log out of your Yavy account').action(() => { diff --git a/src/commands/projects.test.ts b/src/commands/projects.test.ts index c3435ea..54a434c 100644 --- a/src/commands/projects.test.ts +++ b/src/commands/projects.test.ts @@ -1,18 +1,21 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { projectsCommand } from './projects.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { projectsCommand } from './projects'; -vi.mock('../api/client.js', () => ({ +vi.mock('../api/client', () => ({ YavyApiClient: { create: vi.fn(), }, })); -vi.mock('../utils/output.js', () => ({ +vi.mock('../utils/output', () => ({ error: vi.fn(), })); -vi.mock('../config.js', () => ({ +vi.mock('../config', () => ({ YAVY_BASE_URL: 'https://test.yavy.dev', + YAVY_USER_AGENT: '@yavydev/cli', + REQUEST_TIMEOUT_MS: 30_000, + MAX_RETRIES: 3, })); vi.mock('chalk', () => ({ @@ -33,8 +36,8 @@ vi.mock('ora', () => ({ })), })); -import { YavyApiClient } from '../api/client.js'; -import { error } from '../utils/output.js'; +import { YavyApiClient } from '../api/client'; +import { error } from '../utils/output'; function createMockClient(projects: unknown[] = []) { return { diff --git a/src/commands/projects.ts b/src/commands/projects.ts index eaf55f6..9f2a070 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,9 +1,9 @@ import chalk from 'chalk'; import { Command } from 'commander'; import ora from 'ora'; -import { YavyApiClient } from '../api/client.js'; -import { YAVY_BASE_URL } from '../config.js'; -import { error } from '../utils/output.js'; +import { YavyApiClient } from '../api/client'; +import { YAVY_BASE_URL } from '../config'; +import { error } from '../utils'; export function projectsCommand(): Command { return new Command('projects') diff --git a/src/config.ts b/src/config.ts index 80dcc3a..085de56 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,2 +1,5 @@ -export const YAVY_BASE_URL = process.env.YAVY_BASE_URL ?? 'https://buckaroo-test.ngrok.app'; +export const YAVY_BASE_URL = process.env.YAVY_BASE_URL ?? 'https://yavy.dev'; export const YAVY_CLIENT_ID = process.env.YAVY_CLIENT_ID ?? '01965e6a-0000-7000-8000-000000000001'; +export const YAVY_USER_AGENT = `@yavydev/cli`; +export const REQUEST_TIMEOUT_MS = 30_000; +export const MAX_RETRIES = 3; diff --git a/src/index.ts b/src/index.ts index 22473af..169aa0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import { Command } from 'commander'; import pkg from '../package.json' with { type: 'json' }; -import { generateCommand } from './commands/generate.js'; -import { loginCommand } from './commands/login.js'; -import { logoutCommand } from './commands/logout.js'; -import { projectsCommand } from './commands/projects.js'; +import { generateCommand } from './commands/generate'; +import { loginCommand } from './commands/login'; +import { logoutCommand } from './commands/logout'; +import { projectsCommand } from './commands/projects'; +import { error } from './utils'; const program = new Command(); @@ -14,4 +15,7 @@ program.addCommand(logoutCommand()); program.addCommand(projectsCommand()); program.addCommand(generateCommand()); -program.parse(); +program.parseAsync().catch((err: unknown) => { + error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..be28235 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export { success, error, warn, info } from './output'; +export { getSkillOutputDir, ensureDir, isPathSafe } from './paths'; diff --git a/src/utils/output.test.ts b/src/utils/output.test.ts index c2be4e6..17eca97 100644 --- a/src/utils/output.test.ts +++ b/src/utils/output.test.ts @@ -1,11 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { success, error, info } from './output.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { error, info, success, warn } from './output'; vi.mock('chalk', () => ({ default: { green: (s: string) => s, red: (s: string) => s, blue: (s: string) => s, + yellow: (s: string) => s, }, })); @@ -28,6 +29,12 @@ describe('output utilities', () => { expect(console.error).toHaveBeenCalledWith(expect.stringContaining('it failed')); }); + it('warn() calls console.error with message', () => { + warn('be careful'); + expect(console.error).toHaveBeenCalledOnce(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('be careful')); + }); + it('info() calls console.log with message', () => { info('some info'); expect(console.log).toHaveBeenCalledOnce(); @@ -46,6 +53,12 @@ describe('output utilities', () => { expect(call).toContain('✗'); }); + it('warn() includes warning symbol', () => { + warn('heads up'); + const call = vi.mocked(console.error).mock.calls[0][0] as string; + expect(call).toContain('⚠'); + }); + it('info() includes info symbol', () => { info('note'); const call = vi.mocked(console.log).mock.calls[0][0] as string; diff --git a/src/utils/output.ts b/src/utils/output.ts index 9e30b72..f04bf9f 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -8,7 +8,10 @@ export function error(message: string): void { console.error(chalk.red('✗') + ' ' + message); } +export function warn(message: string): void { + console.error(chalk.yellow('⚠') + ' ' + message); +} + export function info(message: string): void { console.log(chalk.blue('ℹ') + ' ' + message); } - diff --git a/src/utils/paths.test.ts b/src/utils/paths.test.ts index b97028b..2ba0e1d 100644 --- a/src/utils/paths.test.ts +++ b/src/utils/paths.test.ts @@ -1,10 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { existsSync, mkdirSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { getSkillOutputDir, ensureDir, ensureParentDir } from './paths.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ensureDir, getSkillOutputDir, isPathSafe } from './paths'; vi.mock('node:fs', () => ({ - existsSync: vi.fn(), mkdirSync: vi.fn(), })); @@ -34,23 +33,32 @@ describe('getSkillOutputDir', () => { }); describe('ensureDir', () => { - it('calls mkdirSync with recursive when dir does not exist', () => { - vi.mocked(existsSync).mockReturnValue(false); + it('calls mkdirSync with recursive unconditionally', () => { ensureDir('/some/nested/dir'); expect(mkdirSync).toHaveBeenCalledWith('/some/nested/dir', { recursive: true }); }); +}); - it('skips mkdirSync when dir already exists', () => { - vi.mocked(existsSync).mockReturnValue(true); - ensureDir('/existing/dir'); - expect(mkdirSync).not.toHaveBeenCalled(); +describe('isPathSafe', () => { + it('returns true for normal relative paths', () => { + expect(isPathSafe('SKILL.md', '/output')).toBe(true); + expect(isPathSafe('references/doc.md', '/output')).toBe(true); }); -}); -describe('ensureParentDir', () => { - it('calls mkdirSync for parent directory', () => { - vi.mocked(existsSync).mockReturnValue(false); - ensureParentDir('/some/nested/dir/file.md'); - expect(mkdirSync).toHaveBeenCalledWith('/some/nested/dir', { recursive: true }); + it('returns false for paths with ../ traversal', () => { + expect(isPathSafe('../../../etc/passwd', '/output')).toBe(false); + expect(isPathSafe('references/../../secret.txt', '/output')).toBe(false); + }); + + it('returns false for paths with null bytes', () => { + expect(isPathSafe('file\0.md', '/output')).toBe(false); + }); + + it('returns true for deeply nested safe paths', () => { + expect(isPathSafe('a/b/c/d/file.md', '/output')).toBe(true); + }); + + it('returns false for absolute path escapes', () => { + expect(isPathSafe('/etc/passwd', '/output')).toBe(false); }); }); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 3118d1c..f80b828 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdirSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join, resolve, relative } from 'node:path'; export function getSkillOutputDir(projectSlug: string, options: { global?: boolean; output?: string }): string { if (options.output) { @@ -13,12 +13,15 @@ export function getSkillOutputDir(projectSlug: string, options: { global?: boole } export function ensureDir(dirPath: string): void { - if (!existsSync(dirPath)) { - mkdirSync(dirPath, { recursive: true }); - } + mkdirSync(dirPath, { recursive: true }); } -export function ensureParentDir(filePath: string): void { - const dir = dirname(filePath); - ensureDir(dir); +/** + * Validates that a resolved file path is contained within the expected root directory. + * Prevents zip-slip / path traversal attacks. + */ +export function isPathSafe(filePath: string, rootDir: string): boolean { + const resolvedPath = resolve(rootDir, filePath); + const rel = relative(rootDir, resolvedPath); + return !rel.startsWith('..') && !resolve(resolvedPath).includes('\0'); }