diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ce8e2f..c6a2fe9 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,4 +22,5 @@ jobs: - run: npm ci - run: npm run typecheck + - run: npm run lint - run: npm run test diff --git a/.gitignore b/.gitignore index 5c53971..ca38bd0 100755 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist .omc *.log *.tgz +.claude \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..692f8cb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# @yavydev/cli + +CLI for searching, managing, and configuring AI-ready documentation on Yavy. + +## Commands + +```bash +npm install # Install dependencies +npm run build # Build with tsup +npm run dev # Build in watch mode +npm run check # Run all checks (typecheck + lint + format + test) +npm run typecheck # TypeScript strict type checking +npm run lint # ESLint with typescript-eslint +npm run lint:fix # ESLint auto-fix +npm run test # Run tests (vitest) +npm run format:check # Check formatting (prettier) +npm run format # Fix formatting +``` + +## Architecture + +The CLI is built on Commander.js with a modular command structure. Each command is a factory function returning a `Command` instance, registered in the entry point. + +- **Commands** - one directory or file per command group. Each exports a factory. +- **API Client** - token-based HTTP client with retry logic and exponential backoff. +- **Auth** - OAuth2 PKCE flow with local callback server; credentials stored in `~/.yavy/`. +- **Prompts** - interactive flows using @clack/prompts for multi-select and @inquirer/prompts for input/select. + +See [docs/architecture.md](docs/architecture.md) for details. + +## Key Design Decisions + +- `@/` path aliases throughout (configured in tsconfig, tsup, vitest). +- Strict TypeScript: `strict`, `noUncheckedIndexedAccess`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch`. +- ESLint with typescript-eslint: `consistent-type-imports`, `no-floating-promises`, `no-explicit-any`. +- Commands set `process.exitCode` instead of calling `process.exit()` directly - keeps code testable. +- Two auth patterns coexist: OAuth (login flow) and token-based (API token via env or config file). +- Interactive mode activates when required CLI flags are missing; flags always take precedence. + +## After Changing Commands + +- Register new commands in the entry point. +- Add the command to README.md under the Commands section. +- If adding a new command group, create a directory under `src/commands/`. + +## After Changing the API Client + +- Update tests that mock fetch globally. +- If adding new response types, define them alongside existing API interfaces in the client module. + +## Documentation + +- [Architecture](docs/architecture.md) - layers, data flow, design decisions +- [README](README.md) - install, quick start, command reference diff --git a/README.md b/README.md index c3b9ed7..1736029 100755 --- a/README.md +++ b/README.md @@ -63,6 +63,22 @@ Lists all projects you have access to across your organizations. | -------- | -------------- | | `--json` | Output as JSON | +### `yavy project create` + +Create a new documentation project. Runs interactively when `--url` or `--github` is omitted. + +| Flag | Description | +| ----------------- | ---------------------------------------- | +| `--url ` | Documentation URL (web crawl source) | +| `--github ` | GitHub repository (e.g. laravel/docs) | +| `--org ` | Organization slug | +| `--name ` | Project name (auto-generated if omitted) | +| `--public` | Make project public (default) | +| `--private` | Make project private | +| `--branch ` | GitHub branch override | +| `--docs-path

` | GitHub docs path | +| `--no-sync` | Skip initial auto-sync | + ### `yavy generate ` Downloads a skill from a project's indexed documentation. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3e71441 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,61 @@ +# Architecture + +How the CLI is structured, where things live, and why. + +## Layers + +### Entry Point + +`bin/yavy.js` is the shebang entry that loads the built output. The source entry registers all commands with Commander.js and calls `parseAsync()`. Top-level error handler catches unhandled rejections and exits with code 1. + +### Commands + +Each command is a factory function that returns a configured `Command`. Commands live in `src/commands/` - either as single files (login, logout, search) or directories when they have submodules (init, project). + +Command factories wire up flags, descriptions, and an async action handler. The action handler orchestrates: validate inputs, call API, format output. No business logic lives in the action - it delegates to dedicated modules. + +### API Client + +Single HTTP client class handles all Yavy API communication. Key behaviors: + +- Bearer token auth (loaded from credential store or environment) +- Retry with exponential backoff on 429/502/503/504 +- Request timeout with AbortController +- Typed response parsing + +### Authentication + +Two paths: + +1. **OAuth PKCE** - `yavy login` opens browser, spawns local HTTP server for callback, exchanges code for token. Credentials persisted to `~/.yavy/credentials.json` with 0600 permissions. Auto-refresh when token nears expiry. +2. **Token-based** - `YAVY_API_TOKEN` env variable or `~/.yavy/config.json`. Used by the project creation flow and CI environments. + +### Interactive Prompts + +When required CLI flags are missing, commands fall back to interactive mode. Prompts collect the missing values, then merge with any flags that were provided. Two prompt libraries are in use: @clack/prompts (multi-select, spinners) and @inquirer/prompts (input, select). + +### Utilities + +- **Output** - colored terminal helpers (success, error, warn, info) using chalk +- **Paths** - skill output directories, zip-slip prevention, safe directory creation +- **Errors** - API error formatting that maps HTTP status codes to actionable messages + +## Data Flow + +``` +User runs command + -> Commander parses flags + -> Command action handler runs + -> If missing flags: interactive prompts fill them in + -> API client makes request (with retry) + -> Response formatted and printed to stdout + -> Errors caught, formatted, printed to stderr +``` + +## Build & Distribution + +tsup bundles to ESM targeting Node 20+. The dist includes declarations and source maps. Published to npm as `@yavydev/cli` with `dist/` and `bin/` in the package. + +## Testing Strategy + +Vitest with globals enabled. Tests mock at module boundaries - the API client, prompts, and filesystem are mocked; pure functions (payload builders, error formatters, org extractors) are tested directly. Console output is verified via spies on console.log/console.error. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..d4f92a0 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,24 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-floating-promises': 'error', + }, + }, + { + ignores: ['dist/', 'node_modules/', '*.config.*'], + }, +); diff --git a/package-lock.json b/package-lock.json index 6591c40..91e85ce 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yavydev/cli", - "version": "0.2.0", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yavydev/cli", - "version": "0.2.0", + "version": "0.2.3", "license": "MIT", "dependencies": { "@clack/prompts": "1.1.0", @@ -20,11 +20,14 @@ "yavy": "bin/yavy.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.1.0", + "eslint": "^10.2.0", "prettier": "^3.8.1", "tsup": "^8.5.1", "typescript": "^5.9.3", + "typescript-eslint": "^8.58.1", "vitest": "^4.1.0" }, "engines": { @@ -586,6 +589,186 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1300,6 +1483,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1307,17 +1497,253 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", @@ -1463,9 +1889,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -1475,6 +1901,33 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1516,6 +1969,29 @@ "js-tokens": "^10.0.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1655,6 +2131,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1673,6 +2164,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -1737,7 +2235,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1773,6 +2270,161 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1783,6 +2435,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1793,6 +2455,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1817,6 +2500,36 @@ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/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", @@ -1829,6 +2542,27 @@ "rollup": "^4.34.8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1856,6 +2590,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1873,6 +2620,26 @@ "dev": true, "license": "MIT" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1888,6 +2655,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -1957,6 +2747,13 @@ "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", @@ -2013,6 +2810,51 @@ "dev": true, "license": "MIT" }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2304,6 +3146,22 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -2370,6 +3228,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -2421,6 +3295,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2477,6 +3358,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", @@ -2499,6 +3398,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2519,7 +3470,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2569,7 +3519,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2634,6 +3583,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -2650,6 +3609,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2794,6 +3763,29 @@ "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", @@ -3016,6 +4008,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -3084,13 +4089,25 @@ } } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3099,6 +4116,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -3113,13 +4154,22 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", @@ -3198,7 +4248,6 @@ "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", @@ -3285,6 +4334,22 @@ "node": ">=18" } }, + "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", @@ -3302,6 +4367,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -3318,6 +4393,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index 00e3f6c..5ebcc1e 100755 --- a/package.json +++ b/package.json @@ -13,8 +13,11 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", "format": "prettier --write \"src/**/*.{ts,js}\" \"tests/**/*.{ts,js}\" \"*.{json,md}\"", - "format:check": "prettier --check \"src/**/*.{ts,js}\" \"tests/**/*.{ts,js}\" \"*.{json,md}\"" + "format:check": "prettier --check \"src/**/*.{ts,js}\" \"tests/**/*.{ts,js}\" \"*.{json,md}\"", + "check": "npm run typecheck && npm run lint && npm run format:check && npm run test" }, "keywords": [ "yavy", @@ -44,11 +47,14 @@ "ora": "^9.3.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.1.0", + "eslint": "^10.2.0", "prettier": "^3.8.1", "tsup": "^8.5.1", "typescript": "^5.9.3", + "typescript-eslint": "^8.58.1", "vitest": "^4.1.0" }, "engines": { diff --git a/src/api/client.ts b/src/api/client.ts index 926458c..ba1ebab 100755 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,6 +1,22 @@ import { getAccessToken } from '@/auth/store'; import { MAX_RETRIES, REQUEST_TIMEOUT_MS, YAVY_BASE_URL, YAVY_USER_AGENT } from '@/config'; +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + message?: string, + ) { + super(message ?? `API request failed with status ${status}`); + this.name = 'ApiError'; + } +} + +export interface OrganizationInfo { + name: string; + slug: string; +} + export interface ProjectContext { product: string | null; type: string | null; @@ -21,16 +37,19 @@ export interface ApiProject { name: string; slug: string; description: string | null; - organization: { - name: string; - slug: string; - }; + organization: OrganizationInfo; pages_count: number; last_indexed_at: string | null; has_indexed_content: boolean; + mcp_url: string; context: ProjectContext; } +export interface ApiValidationError { + message: string; + errors: Record; +} + export interface SearchResult { title: string; url: string; @@ -112,12 +131,14 @@ export class YavyApiClient { } private async handleErrorResponse(response: Response): Promise { + const body = await response.json().catch(() => ({})); + if (response.status === 401) { - throw new Error('Authentication expired. Run `yavy login` to re-authenticate.'); + throw new ApiError(401, body, '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}`); + const errorData = body as { error?: string }; + throw new ApiError(response.status, body, errorData.error ?? `API request failed with status ${response.status}`); } private async request(method: string, path: string, body?: unknown): Promise { @@ -146,6 +167,10 @@ export class YavyApiClient { return result.data; } + async createProject(orgSlug: string, payload: unknown): Promise<{ data: ApiProject }> { + return this.request<{ data: ApiProject }>('POST', `/${encodeURIComponent(orgSlug)}/projects`, payload); + } + async search(query: string, options?: { project?: string; limit?: number }): Promise { const params = new URLSearchParams({ query }); if (options?.project) params.set('project', options.project); diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 410a577..48b74c9 100755 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -145,7 +145,7 @@ export async function performOAuthLogin(): Promise { authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); - open(authUrl.toString()); + void open(authUrl.toString()); }) .catch((err) => { server.close(); diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 18038a7..4f48ca3 100755 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -22,7 +22,8 @@ export function generateCommand(): Command { process.exit(1); } - const [orgSlug, projectSlug] = parts; + const orgSlug = parts[0]!; + const projectSlug = parts[1]!; const outputDir = getSkillOutputDir(projectSlug, options); if (!options.force && existsSync(join(outputDir, 'SKILL.md'))) { diff --git a/src/commands/init/configure-tool.ts b/src/commands/init/configure-tool.ts index 08b455c..2d73119 100644 --- a/src/commands/init/configure-tool.ts +++ b/src/commands/init/configure-tool.ts @@ -67,7 +67,7 @@ function buildMcpUrl(projects: ApiProject[]): string { ); } - return `${YAVY_BASE_URL}/mcp/${encodeURIComponent(orgSlugs[0])}`; + return `${YAVY_BASE_URL}/mcp/${encodeURIComponent(orgSlugs[0]!)}`; } function readJsonFile(configPath: string): Record { diff --git a/src/commands/project/build-request.ts b/src/commands/project/build-request.ts new file mode 100644 index 0000000..123c033 --- /dev/null +++ b/src/commands/project/build-request.ts @@ -0,0 +1,39 @@ +import type { CreateProjectOptions, CreateProjectPayload } from '@/commands/project/types'; + +export function buildCreateProjectPayload(options: CreateProjectOptions): CreateProjectPayload { + if (options.url) { + return buildWebCrawlPayload(options); + } + + if (options.github) { + return buildGitHubPayload(options); + } + + throw new Error('Either --url or --github is required.'); +} + +function buildWebCrawlPayload(options: CreateProjectOptions): CreateProjectPayload { + return { + url_discovery_mode: 'web_crawl', + base_url: options.url, + ...sharedFields(options), + }; +} + +function buildGitHubPayload(options: CreateProjectOptions): CreateProjectPayload { + return { + url_discovery_mode: 'github_repository', + github_repo: options.github, + ...(options.branch && { github_branch: options.branch }), + ...(options.docsPath && { github_docs_path: options.docsPath }), + ...sharedFields(options), + }; +} + +function sharedFields(options: CreateProjectOptions): Pick { + return { + ...(options.name && { name: options.name }), + is_public: !options.private, + ...(options.noSync && { no_sync: true }), + }; +} diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts new file mode 100644 index 0000000..1e02968 --- /dev/null +++ b/src/commands/project/create.ts @@ -0,0 +1,64 @@ +import { Command } from 'commander'; +import { YavyApiClient } from '@/api/client'; +import { needsInteractiveMode, runInteractiveFlow } from '@/prompts/project-create'; +import { error, formatProjectCreated } from '@/utils'; +import { buildCreateProjectPayload } from '@/commands/project/build-request'; +import { resolveOrg } from '@/commands/project/resolve-org'; +import type { CreateProjectOptions } from '@/commands/project/types'; + +export function createProjectCommand(): Command { + return new Command('create') + .description('Create a new documentation project') + .option('--url ', 'Documentation URL (WebCrawl source)') + .option('--github ', 'GitHub repository (e.g. laravel/docs)') + .option('--org ', 'Organization slug') + .option('--name ', 'Project name (auto-generated if omitted)') + .option('--public', 'Make project public (default)') + .option('--private', 'Make project private') + .option('--branch ', 'GitHub branch override') + .option('--docs-path ', 'GitHub docs path') + .option('--no-sync', 'Skip initial auto-sync') + .action(async (options: CreateProjectOptions) => { + try { + await executeCreateProject(options); + } catch (err) { + if (err instanceof Error && err.message === 'cancelled') { + console.log('\nProject creation cancelled.'); + return; + } + error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); +} + +export async function executeCreateProject(options: CreateProjectOptions): Promise { + const client = await YavyApiClient.create(); + + let resolvedOptions = options; + + if (needsInteractiveMode(options)) { + resolvedOptions = await runInteractiveFlow(client, options); + } + + if (!resolvedOptions.url && !resolvedOptions.github) { + throw new Error('Either --url or --github is required.'); + } + + if (resolvedOptions.url && resolvedOptions.github) { + throw new Error('Provide either --url or --github, not both.'); + } + + const projects = await client.listProjects(); + const { slug: orgSlug, orgs } = await resolveOrg(projects, resolvedOptions.org); + + if (!orgSlug) { + const slugList = orgs.map((o) => ` - ${o.slug} (${o.name})`).join('\n'); + throw new Error(`Multiple organizations found. Please specify one with --org :\n${slugList}`); + } + + const payload = buildCreateProjectPayload(resolvedOptions); + const response = await client.createProject(orgSlug, payload); + + console.log(formatProjectCreated(response.data)); +} diff --git a/src/commands/project/resolve-org.ts b/src/commands/project/resolve-org.ts new file mode 100644 index 0000000..7a4e57c --- /dev/null +++ b/src/commands/project/resolve-org.ts @@ -0,0 +1,33 @@ +import type { ApiProject, OrganizationInfo } from '@/api/client'; + +export function extractUniqueOrgs(projects: Array<{ organization: OrganizationInfo }>): OrganizationInfo[] { + const seen = new Set(); + const orgs: OrganizationInfo[] = []; + + for (const project of projects) { + if (!seen.has(project.organization.slug)) { + seen.add(project.organization.slug); + orgs.push(project.organization); + } + } + + return orgs; +} + +export async function resolveOrg(projects: ApiProject[], orgFlag: string | undefined): Promise<{ slug: string; orgs: OrganizationInfo[] }> { + if (orgFlag) { + return { slug: orgFlag, orgs: [] }; + } + + const orgs = extractUniqueOrgs(projects); + + if (orgs.length === 0) { + throw new Error('No organizations found. Please specify an organization with --org .'); + } + + if (orgs.length === 1 && orgs[0]) { + return { slug: orgs[0].slug, orgs }; + } + + return { slug: '', orgs }; +} diff --git a/src/commands/project/types.ts b/src/commands/project/types.ts new file mode 100644 index 0000000..0cf6dfa --- /dev/null +++ b/src/commands/project/types.ts @@ -0,0 +1,22 @@ +export interface CreateProjectOptions { + url?: string; + github?: string; + org?: string; + name?: string; + public?: boolean; + private?: boolean; + branch?: string; + docsPath?: string; + noSync?: boolean; +} + +export interface CreateProjectPayload { + url_discovery_mode: 'web_crawl' | 'github_repository'; + base_url?: string; + github_repo?: string; + github_branch?: string; + github_docs_path?: string; + name?: string; + is_public?: boolean; + no_sync?: boolean; +} diff --git a/src/index.ts b/src/index.ts index 0dea18d..b2e8c4d 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { generateCommand } from '@/commands/generate'; import { initCommand } from '@/commands/init'; import { loginCommand } from '@/commands/login'; import { logoutCommand } from '@/commands/logout'; +import { createProjectCommand } from '@/commands/project/create'; import { projectsCommand } from '@/commands/projects'; import { searchCommand } from '@/commands/search'; import { error } from '@/utils'; @@ -12,12 +13,16 @@ const program = new Command(); program.name('yavy').description('Search and manage your AI-ready documentation').version(pkg.version); +const projectCmd = new Command('project').description('Manage documentation projects'); +projectCmd.addCommand(createProjectCommand()); + program.addCommand(loginCommand()); program.addCommand(logoutCommand()); program.addCommand(projectsCommand()); program.addCommand(searchCommand()); program.addCommand(generateCommand()); program.addCommand(initCommand()); +program.addCommand(projectCmd); program.parseAsync().catch((err: unknown) => { error(err instanceof Error ? err.message : String(err)); diff --git a/src/prompts/project-create.ts b/src/prompts/project-create.ts new file mode 100644 index 0000000..7999019 --- /dev/null +++ b/src/prompts/project-create.ts @@ -0,0 +1,133 @@ +import * as p from '@clack/prompts'; +import type { YavyApiClient } from '@/api/client'; +import type { OrganizationInfo } from '@/api/client'; +import { extractUniqueOrgs, resolveOrg } from '@/commands/project/resolve-org'; +import type { CreateProjectOptions } from '@/commands/project/types'; + +type SourceType = 'web_crawl' | 'github_repository'; + +export function needsInteractiveMode(options: CreateProjectOptions): boolean { + return !options.url && !options.github; +} + +export async function runInteractiveFlow(client: YavyApiClient, options: CreateProjectOptions): Promise { + const source = await collectSourceFromPrompts(); + const orgSlug = await resolveOrgInteractively(client, options.org); + + return { + ...options, + ...source, + org: orgSlug, + }; +} + +export async function collectSourceFromPrompts(): Promise> { + const sourceType = await promptSourceType(); + + if (sourceType === 'web_crawl') { + const url = await promptUrl(); + return { url }; + } + + const github = await promptGitHub(); + return { github }; +} + +export async function resolveOrgInteractively(client: YavyApiClient, orgFlag: string | undefined): Promise { + if (orgFlag) { + return orgFlag; + } + + const projects = await client.listProjects(); + const { slug, orgs } = await resolveOrg(projects, undefined); + + if (slug) { + return slug; + } + + return promptOrgSelection(orgs); +} + +export async function fetchUserOrgs(client: YavyApiClient): Promise { + const projects = await client.listProjects(); + return extractUniqueOrgs(projects); +} + +async function promptSourceType(): Promise { + const result = await p.select({ + message: 'What type of documentation source?', + options: [ + { label: 'Web Crawl (URL)', value: 'web_crawl' as const }, + { label: 'GitHub Repository', value: 'github_repository' as const }, + ], + }); + + if (p.isCancel(result)) { + throw new Error('cancelled'); + } + + return result; +} + +async function promptUrl(): Promise { + const result = await p.text({ + message: 'Documentation URL:', + validate: (value) => { + if (!value?.trim()) { + return 'URL is required.'; + } + + try { + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return 'URL must use http or https.'; + } + } catch { + return 'Please enter a valid URL (e.g. https://docs.example.com).'; + } + }, + }); + + if (p.isCancel(result)) { + throw new Error('cancelled'); + } + + return result; +} + +async function promptGitHub(): Promise { + const result = await p.text({ + message: 'GitHub repository (owner/repo):', + validate: (value) => { + if (!value?.trim()) { + return 'Repository is required.'; + } + + if (!/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(value)) { + return 'Please enter a valid owner/repo format (e.g. laravel/docs).'; + } + }, + }); + + if (p.isCancel(result)) { + throw new Error('cancelled'); + } + + return result; +} + +async function promptOrgSelection(orgs: OrganizationInfo[]): Promise { + const result = await p.select({ + message: 'Which organization?', + options: orgs.map((org) => ({ + label: `${org.name} (${org.slug})`, + value: org.slug, + })), + }); + + if (p.isCancel(result)) { + throw new Error('cancelled'); + } + + return result; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..ac82b95 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,57 @@ +import { ApiError, type ApiValidationError } from '@/api/client'; + +export function formatApiError(error: unknown): string { + if (!(error instanceof ApiError)) { + if (error instanceof Error) { + return `Error: ${error.message}`; + } + return 'An unexpected error occurred.'; + } + + switch (error.status) { + case 401: + return 'Authentication expired. Run `yavy login` to re-authenticate.'; + + case 403: + return 'Permission denied. You do not have access to this organization.'; + + case 404: + return 'Not found. Check that the organization slug is correct.'; + + case 422: + return formatValidationErrors(error.body); + + case 429: + return 'Rate limit exceeded. Please wait a moment and try again.'; + + default: + return `API error (${error.status}): ${extractMessage(error.body)}`; + } +} + +function formatValidationErrors(body: unknown): string { + if (!isValidationError(body)) { + return 'Validation failed. Check your input and try again.'; + } + + const lines = ['Validation failed:']; + + for (const [field, messages] of Object.entries(body.errors)) { + for (const message of messages) { + lines.push(` ${field}: ${message}`); + } + } + + return lines.join('\n'); +} + +function isValidationError(body: unknown): body is ApiValidationError { + return typeof body === 'object' && body !== null && 'errors' in body && typeof (body as ApiValidationError).errors === 'object'; +} + +function extractMessage(body: unknown): string { + if (typeof body === 'object' && body !== null && 'message' in body) { + return String((body as { message: string }).message); + } + return 'Unknown error'; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 3e37a57..9597a19 100755 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,2 @@ -export { success, error, warn, info } from '@/utils/output'; +export { success, error, warn, info, formatProjectCreated } from '@/utils/output'; export { getSkillOutputDir, ensureDir, isPathSafe } from '@/utils/paths'; diff --git a/src/utils/output.ts b/src/utils/output.ts index f04bf9f..ede0d51 100755 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import type { ApiProject } from '@/api/client'; export function success(message: string): void { console.log(chalk.green('✓') + ' ' + message); @@ -15,3 +16,16 @@ export function warn(message: string): void { export function info(message: string): void { console.log(chalk.blue('ℹ') + ' ' + message); } + +export function formatProjectCreated(project: ApiProject): string { + return [ + '', + ` Project created successfully!`, + '', + ` Name: ${project.name}`, + ` Org: ${project.organization.name} (${project.organization.slug})`, + ` Slug: ${project.slug}`, + ` MCP URL: ${project.mcp_url}`, + '', + ].join('\n'); +} diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index d8958e2..d370cc5 100755 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockResponse } from '../helpers'; -import { YavyApiClient } from '@/api/client'; +import { ApiError, YavyApiClient } from '@/api/client'; vi.mock('@/auth/store', () => ({ getAccessToken: vi.fn(), @@ -259,3 +259,137 @@ describe('retry behavior', () => { expect(fetch).toHaveBeenCalledTimes(1); }); }); + +describe('createProject', () => { + let client: YavyApiClient; + + beforeEach(async () => { + vi.stubGlobal('fetch', vi.fn()); + vi.mocked(getAccessToken).mockResolvedValue('test-token'); + client = await YavyApiClient.create(); + }); + + it('sends POST to correct path with payload', async () => { + const responseData = { + data: { + id: 1, + name: 'New Project', + slug: 'new-project', + mcp_url: 'https://test.yavy.dev/mcp/org/new-project', + organization: { name: 'Org', slug: 'org' }, + }, + }; + vi.mocked(fetch).mockResolvedValue(createMockResponse(responseData, 201)); + + const payload = { + url_discovery_mode: 'web_crawl' as const, + base_url: 'https://docs.example.com', + is_public: true, + }; + + await client.createProject('my-org', payload); + + expect(fetch).toHaveBeenLastCalledWith( + 'https://test.yavy.dev/api/v1/my-org/projects', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + }), + body: JSON.stringify(payload), + }), + ); + }); + + it('returns the created project', async () => { + const responseData = { + data: { + id: 1, + name: 'New Project', + slug: 'new-project', + mcp_url: 'https://test.yavy.dev/mcp/org/new-project', + organization: { name: 'Org', slug: 'org' }, + }, + }; + vi.mocked(fetch).mockResolvedValue(createMockResponse(responseData, 201)); + + const result = await client.createProject('my-org', { + url_discovery_mode: 'web_crawl', + base_url: 'https://docs.example.com', + }); + + expect(result.data.name).toBe('New Project'); + expect(result.data.mcp_url).toBe('https://test.yavy.dev/mcp/org/new-project'); + }); + + it('encodes org slug in URL', async () => { + vi.mocked(fetch).mockResolvedValue(createMockResponse({ data: {} }, 201)); + + await client.createProject('my org', { url_discovery_mode: 'web_crawl' }); + + const url = vi.mocked(fetch).mock.calls[0][0] as string; + expect(url).toContain('/my%20org/projects'); + }); +}); + +describe('ApiError', () => { + it('is an instance of Error', () => { + const err = new ApiError(422, { errors: {} }); + expect(err).toBeInstanceOf(Error); + }); + + it('exposes status and body', () => { + const body = { message: 'Validation failed', errors: { name: ['required'] } }; + const err = new ApiError(422, body); + + expect(err.status).toBe(422); + expect(err.body).toBe(body); + }); + + it('uses custom message when provided', () => { + const err = new ApiError(401, {}, 'Auth expired'); + expect(err.message).toBe('Auth expired'); + }); + + it('generates default message from status', () => { + const err = new ApiError(500, {}); + expect(err.message).toContain('500'); + }); +}); + +describe('error handling throws ApiError', () => { + let client: YavyApiClient; + + beforeEach(async () => { + vi.stubGlobal('fetch', vi.fn()); + vi.mocked(getAccessToken).mockResolvedValue('test-token'); + client = await YavyApiClient.create(); + }); + + it('throws ApiError on 401', async () => { + vi.mocked(fetch).mockResolvedValue(createMockResponse({}, 401)); + + try { + await client.listProjects(); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(401); + } + }); + + it('throws ApiError on 422 with body', async () => { + const errorBody = { message: 'Validation failed', errors: { base_url: ['Required'] } }; + vi.mocked(fetch).mockResolvedValue(createMockResponse(errorBody, 422)); + + try { + await client.listProjects(); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(422); + expect((err as ApiError).body).toEqual(errorBody); + } + }); +}); diff --git a/tests/commands/project/build-request.test.ts b/tests/commands/project/build-request.test.ts new file mode 100644 index 0000000..e06c311 --- /dev/null +++ b/tests/commands/project/build-request.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { buildCreateProjectPayload } from '@/commands/project/build-request'; + +describe('buildCreateProjectPayload', () => { + it('builds a web_crawl payload from --url', () => { + const payload = buildCreateProjectPayload({ + url: 'https://docs.example.com', + }); + + expect(payload).toEqual({ + url_discovery_mode: 'web_crawl', + base_url: 'https://docs.example.com', + is_public: true, + }); + }); + + it('builds a github_repository payload from --github', () => { + const payload = buildCreateProjectPayload({ + github: 'laravel/docs', + }); + + expect(payload).toEqual({ + url_discovery_mode: 'github_repository', + github_repo: 'laravel/docs', + is_public: true, + }); + }); + + it('includes optional github fields', () => { + const payload = buildCreateProjectPayload({ + github: 'laravel/docs', + branch: 'main', + docsPath: 'docs/', + name: 'Laravel Docs', + }); + + expect(payload).toEqual({ + url_discovery_mode: 'github_repository', + github_repo: 'laravel/docs', + github_branch: 'main', + github_docs_path: 'docs/', + name: 'Laravel Docs', + is_public: true, + }); + }); + + it('sets is_public to false when --private is used', () => { + const payload = buildCreateProjectPayload({ + url: 'https://docs.example.com', + private: true, + }); + + expect(payload.is_public).toBe(false); + }); + + it('sets is_public to true by default', () => { + const payload = buildCreateProjectPayload({ + url: 'https://docs.example.com', + }); + + expect(payload.is_public).toBe(true); + }); + + it('includes no_sync when --no-sync is used', () => { + const payload = buildCreateProjectPayload({ + url: 'https://docs.example.com', + noSync: true, + }); + + expect(payload.no_sync).toBe(true); + }); + + it('does not include no_sync by default', () => { + const payload = buildCreateProjectPayload({ + url: 'https://docs.example.com', + }); + + expect(payload.no_sync).toBeUndefined(); + }); + + it('includes name when provided', () => { + const payload = buildCreateProjectPayload({ + url: 'https://docs.example.com', + name: 'My Docs', + }); + + expect(payload.name).toBe('My Docs'); + }); + + it('omits name when not provided', () => { + const payload = buildCreateProjectPayload({ + url: 'https://docs.example.com', + }); + + expect(payload.name).toBeUndefined(); + }); + + it('throws when neither --url nor --github is provided', () => { + expect(() => buildCreateProjectPayload({})).toThrow('Either --url or --github is required.'); + }); +}); diff --git a/tests/commands/project/create.test.ts b/tests/commands/project/create.test.ts new file mode 100644 index 0000000..97c7dfb --- /dev/null +++ b/tests/commands/project/create.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { executeCreateProject } from '@/commands/project/create'; + +vi.mock('@/auth/store', () => ({ + getAccessToken: vi.fn(), +})); + +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, +})); + +vi.mock('@/prompts/project-create', () => ({ + needsInteractiveMode: vi.fn(), + runInteractiveFlow: vi.fn(), +})); + +import { getAccessToken } from '@/auth/store'; +import { needsInteractiveMode, runInteractiveFlow } from '@/prompts/project-create'; + +function mockFetchResponse(status: number, body: unknown): void { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + headers: new Headers(), + }), + ); +} + +describe('executeCreateProject', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.mocked(needsInteractiveMode).mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws when no token is available', async () => { + vi.mocked(getAccessToken).mockResolvedValue(null); + + await expect(executeCreateProject({ url: 'https://docs.example.com' })).rejects.toThrow('Not authenticated'); + }); + + it('throws when both --url and --github are provided', async () => { + vi.mocked(getAccessToken).mockResolvedValue('test-token'); + mockFetchResponse(200, { data: [] }); + + await expect( + executeCreateProject({ + url: 'https://docs.example.com', + github: 'owner/repo', + }), + ).rejects.toThrow('not both'); + }); + + it('throws when no source is provided and interactive mode is skipped', async () => { + vi.mocked(getAccessToken).mockResolvedValue('test-token'); + vi.mocked(needsInteractiveMode).mockReturnValue(false); + mockFetchResponse(200, { data: [] }); + + await expect(executeCreateProject({})).rejects.toThrow('--url or --github is required'); + }); + + it('creates a project successfully with --url and --org', async () => { + vi.mocked(getAccessToken).mockResolvedValue('test-token'); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: [{ organization: { name: 'My Org', slug: 'my-org' } }], + }), + headers: new Headers(), + }) + .mockResolvedValueOnce({ + ok: true, + status: 201, + json: () => + Promise.resolve({ + data: { + id: 1, + name: 'Example Docs', + slug: 'example-docs', + description: null, + organization: { name: 'My Org', slug: 'my-org' }, + pages_count: 0, + last_indexed_at: null, + has_indexed_content: false, + mcp_url: 'https://yavy.dev/mcp/my-org/example-docs', + }, + }), + headers: new Headers(), + }); + vi.stubGlobal('fetch', fetchMock); + + await executeCreateProject({ + url: 'https://docs.example.com', + org: 'my-org', + }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Project created successfully!')); + }); + + it('auto-selects org when user has exactly one', async () => { + vi.mocked(getAccessToken).mockResolvedValue('test-token'); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: [{ organization: { name: 'Solo Org', slug: 'solo-org' } }], + }), + headers: new Headers(), + }) + .mockResolvedValueOnce({ + ok: true, + status: 201, + json: () => + Promise.resolve({ + data: { + id: 2, + name: 'Docs', + slug: 'docs', + description: null, + organization: { name: 'Solo Org', slug: 'solo-org' }, + pages_count: 0, + last_indexed_at: null, + has_indexed_content: false, + mcp_url: 'https://yavy.dev/mcp/solo-org/docs', + }, + }), + headers: new Headers(), + }); + vi.stubGlobal('fetch', fetchMock); + + await executeCreateProject({ + github: 'laravel/docs', + }); + + const createCall = fetchMock.mock.calls[1]; + expect(createCall[0]).toContain('/solo-org/projects'); + }); + + it('enters interactive mode when no source flags are provided', async () => { + vi.mocked(getAccessToken).mockResolvedValue('test-token'); + vi.mocked(needsInteractiveMode).mockReturnValue(true); + vi.mocked(runInteractiveFlow).mockResolvedValue({ + url: 'https://docs.example.com', + org: 'my-org', + name: 'Interactive Project', + }); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: [{ organization: { name: 'My Org', slug: 'my-org' } }], + }), + headers: new Headers(), + }) + .mockResolvedValueOnce({ + ok: true, + status: 201, + json: () => + Promise.resolve({ + data: { + id: 3, + name: 'Interactive Project', + slug: 'interactive-project', + description: null, + organization: { name: 'My Org', slug: 'my-org' }, + pages_count: 0, + last_indexed_at: null, + has_indexed_content: false, + mcp_url: 'https://yavy.dev/mcp/my-org/interactive-project', + }, + }), + headers: new Headers(), + }); + vi.stubGlobal('fetch', fetchMock); + + await executeCreateProject({}); + + expect(runInteractiveFlow).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Project created successfully!')); + }); +}); diff --git a/tests/commands/project/resolve-org.test.ts b/tests/commands/project/resolve-org.test.ts new file mode 100644 index 0000000..ba5d9a3 --- /dev/null +++ b/tests/commands/project/resolve-org.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { extractUniqueOrgs, resolveOrg } from '@/commands/project/resolve-org'; + +describe('extractUniqueOrgs', () => { + it('returns unique organizations', () => { + const projects = [ + { organization: { name: 'Acme', slug: 'acme' } }, + { organization: { name: 'Acme', slug: 'acme' } }, + { organization: { name: 'Beta Corp', slug: 'beta-corp' } }, + ]; + + const orgs = extractUniqueOrgs(projects); + + expect(orgs).toEqual([ + { name: 'Acme', slug: 'acme' }, + { name: 'Beta Corp', slug: 'beta-corp' }, + ]); + }); + + it('returns empty array for no projects', () => { + expect(extractUniqueOrgs([])).toEqual([]); + }); + + it('returns single org when all projects belong to one', () => { + const projects = [{ organization: { name: 'Solo', slug: 'solo' } }, { organization: { name: 'Solo', slug: 'solo' } }]; + + const orgs = extractUniqueOrgs(projects); + + expect(orgs).toHaveLength(1); + expect(orgs[0].slug).toBe('solo'); + }); +}); + +describe('resolveOrg', () => { + it('returns org flag directly when provided', async () => { + const result = await resolveOrg([], 'my-org'); + + expect(result.slug).toBe('my-org'); + }); + + it('auto-selects when user has exactly one org', async () => { + const projects = [{ organization: { name: 'Solo Org', slug: 'solo-org' } }] as never[]; + + const result = await resolveOrg(projects, undefined); + + expect(result.slug).toBe('solo-org'); + }); + + it('returns empty slug with orgs list when multiple orgs exist', async () => { + const projects = [{ organization: { name: 'Org A', slug: 'org-a' } }, { organization: { name: 'Org B', slug: 'org-b' } }] as never[]; + + const result = await resolveOrg(projects, undefined); + + expect(result.slug).toBe(''); + expect(result.orgs).toHaveLength(2); + }); + + it('throws when no organizations are found', async () => { + await expect(resolveOrg([], undefined)).rejects.toThrow('No organizations found'); + }); +}); diff --git a/tests/prompts/project-create.test.ts b/tests/prompts/project-create.test.ts new file mode 100644 index 0000000..ae03495 --- /dev/null +++ b/tests/prompts/project-create.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { OrganizationInfo } from '@/api/client'; +import type { CreateProjectOptions } from '@/commands/project/types'; + +vi.mock('@clack/prompts', () => ({ + select: vi.fn(), + text: vi.fn(), + isCancel: vi.fn(() => false), +})); + +vi.mock('@/auth/store', () => ({ + getAccessToken: vi.fn(), +})); + +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, +})); + +vi.mock('@/commands/project/resolve-org', () => ({ + extractUniqueOrgs: vi.fn(), + resolveOrg: vi.fn(), +})); + +import * as p from '@clack/prompts'; +import { resolveOrg } from '@/commands/project/resolve-org'; +import { collectSourceFromPrompts, needsInteractiveMode, resolveOrgInteractively, runInteractiveFlow } from '@/prompts/project-create'; + +describe('needsInteractiveMode', () => { + it('returns true when no source flag is provided', () => { + expect(needsInteractiveMode({})).toBe(true); + }); + + it('returns true when only --org is provided', () => { + expect(needsInteractiveMode({ org: 'my-org' })).toBe(true); + }); + + it('returns false when --url is provided', () => { + expect(needsInteractiveMode({ url: 'https://docs.example.com' })).toBe(false); + }); + + it('returns false when --github is provided', () => { + expect(needsInteractiveMode({ github: 'laravel/docs' })).toBe(false); + }); +}); + +describe('collectSourceFromPrompts', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('prompts for URL when user selects WebCrawl', async () => { + vi.mocked(p.select).mockResolvedValueOnce('web_crawl'); + vi.mocked(p.text).mockResolvedValueOnce('https://docs.example.com'); + + const result = await collectSourceFromPrompts(); + + expect(result).toEqual({ url: 'https://docs.example.com' }); + expect(p.select).toHaveBeenCalledWith(expect.objectContaining({ message: 'What type of documentation source?' })); + }); + + it('prompts for owner/repo when user selects GitHub', async () => { + vi.mocked(p.select).mockResolvedValueOnce('github_repository'); + vi.mocked(p.text).mockResolvedValueOnce('laravel/docs'); + + const result = await collectSourceFromPrompts(); + + expect(result).toEqual({ github: 'laravel/docs' }); + }); +}); + +describe('resolveOrgInteractively', () => { + let mockClient: { listProjects: ReturnType; createProject: ReturnType }; + + beforeEach(() => { + mockClient = { + listProjects: vi.fn().mockResolvedValue([]), + createProject: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the org flag directly when provided', async () => { + const result = await resolveOrgInteractively(mockClient as never, 'my-org'); + + expect(result).toBe('my-org'); + expect(mockClient.listProjects).not.toHaveBeenCalled(); + }); + + it('auto-selects when user has exactly one org', async () => { + mockClient.listProjects.mockResolvedValue([{ organization: { name: 'Solo Org', slug: 'solo-org' } }]); + vi.mocked(resolveOrg).mockResolvedValue({ + slug: 'solo-org', + orgs: [{ name: 'Solo Org', slug: 'solo-org' }], + }); + + const result = await resolveOrgInteractively(mockClient as never, undefined); + + expect(result).toBe('solo-org'); + }); + + it('prompts for selection when user has multiple orgs', async () => { + const orgs: OrganizationInfo[] = [ + { name: 'Org A', slug: 'org-a' }, + { name: 'Org B', slug: 'org-b' }, + ]; + mockClient.listProjects.mockResolvedValue([{ organization: orgs[0] }, { organization: orgs[1] }]); + vi.mocked(resolveOrg).mockResolvedValue({ slug: '', orgs }); + vi.mocked(p.select).mockResolvedValueOnce('org-b'); + + const result = await resolveOrgInteractively(mockClient as never, undefined); + + expect(result).toBe('org-b'); + expect(p.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Which organization?', + }), + ); + }); +}); + +describe('runInteractiveFlow', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('merges prompt results with existing options', async () => { + const mockClient = { + listProjects: vi.fn().mockResolvedValue([{ organization: { name: 'My Org', slug: 'my-org' } }]), + createProject: vi.fn(), + }; + vi.mocked(resolveOrg).mockResolvedValue({ + slug: 'my-org', + orgs: [{ name: 'My Org', slug: 'my-org' }], + }); + vi.mocked(p.select).mockResolvedValueOnce('web_crawl'); + vi.mocked(p.text).mockResolvedValueOnce('https://docs.example.com'); + + const existingOptions: CreateProjectOptions = { name: 'My Project', private: true }; + const result = await runInteractiveFlow(mockClient as never, existingOptions); + + expect(result).toEqual({ + name: 'My Project', + private: true, + url: 'https://docs.example.com', + org: 'my-org', + }); + }); +}); diff --git a/tests/utils/errors.test.ts b/tests/utils/errors.test.ts new file mode 100644 index 0000000..5550ecf --- /dev/null +++ b/tests/utils/errors.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError } from '@/api/client'; +import { formatApiError } from '@/utils/errors'; + +describe('formatApiError', () => { + it('maps 401 to auth error', () => { + const error = new ApiError(401, { message: 'Unauthenticated.' }); + const message = formatApiError(error); + + expect(message).toContain('Authentication expired'); + }); + + it('maps 403 to permission denied', () => { + const error = new ApiError(403, { message: 'Forbidden.' }); + const message = formatApiError(error); + + expect(message).toContain('Permission denied'); + }); + + it('maps 404 to not found', () => { + const error = new ApiError(404, { message: 'Not Found.' }); + const message = formatApiError(error); + + expect(message).toContain('Not found'); + }); + + it('maps 422 to formatted validation errors', () => { + const error = new ApiError(422, { + message: 'The given data was invalid.', + errors: { + base_url: ['The base url field is required.'], + url_discovery_mode: ['Only web_crawl and github_repository are supported.'], + }, + }); + const message = formatApiError(error); + + expect(message).toContain('Validation failed:'); + expect(message).toContain('base_url: The base url field is required.'); + expect(message).toContain('url_discovery_mode: Only web_crawl and github_repository are supported.'); + }); + + it('maps 429 to rate limit message', () => { + const error = new ApiError(429, { message: 'Too Many Attempts.' }); + const message = formatApiError(error); + + expect(message).toContain('Rate limit'); + }); + + it('handles unknown status codes', () => { + const error = new ApiError(500, { message: 'Server Error' }); + const message = formatApiError(error); + + expect(message).toContain('500'); + expect(message).toContain('Server Error'); + }); + + it('handles non-ApiError errors', () => { + const error = new Error('Network failure'); + const message = formatApiError(error); + + expect(message).toContain('Network failure'); + }); + + it('handles unknown error types', () => { + const message = formatApiError('something weird'); + + expect(message).toContain('unexpected error'); + }); + + it('handles 422 with no structured errors', () => { + const error = new ApiError(422, { message: 'Bad request' }); + const message = formatApiError(error); + + expect(message).toContain('Validation failed'); + }); +}); diff --git a/tests/utils/output.test.ts b/tests/utils/output.test.ts index 4aa8a11..ec83c5c 100755 --- a/tests/utils/output.test.ts +++ b/tests/utils/output.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { error, info, success, warn } from '@/utils/output'; +import type { ApiProject } from '@/api/client'; +import { error, formatProjectCreated, info, success, warn } from '@/utils/output'; vi.mock('chalk', () => ({ default: { @@ -65,3 +66,37 @@ describe('output utilities', () => { expect(call).toContain('ℹ'); }); }); + +describe('formatProjectCreated', () => { + const project = { + id: 1, + name: 'Laravel Docs', + slug: 'laravel-docs', + description: null, + organization: { name: 'Acme', slug: 'acme' }, + pages_count: 0, + last_indexed_at: null, + has_indexed_content: false, + mcp_url: 'https://yavy.dev/mcp/acme/laravel-docs', + } as ApiProject; + + it('includes the project name', () => { + expect(formatProjectCreated(project)).toContain('Laravel Docs'); + }); + + it('includes the organization name and slug', () => { + expect(formatProjectCreated(project)).toContain('Acme (acme)'); + }); + + it('includes the project slug', () => { + expect(formatProjectCreated(project)).toContain('laravel-docs'); + }); + + it('includes the MCP URL', () => { + expect(formatProjectCreated(project)).toContain('https://yavy.dev/mcp/acme/laravel-docs'); + }); + + it('includes success message', () => { + expect(formatProjectCreated(project)).toContain('Project created successfully!'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index ad624d5..9e8ec03 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,10 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true,